Files
AIC-Project/Client/Assets/Base/LocalCdnDiscovery.cs
T
2026-07-10 10:24:29 +08:00

229 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
// 底包(AOT)专用:内网 CDN 广播发现。
// 真机内网测试时 CDN 跑在开发机上,客户端写死的 127.0.0.1 在真机上指向手机自身、连不到开发机;
// 故用 UDP 广播问开发机网关要它的 LAN CDN 根地址(形如 http://<开发机IP>:15081/)。
//
// 协议与热更层 XWorld.Framework.Protocol.DevDiscovery / DevServerDiscovery 完全一致
// (magic "XWDL"、UDP 48923、LEB128 varuint + UTF8 长度前缀字符串、token=xw-dev)
// 但 Base 程序集不能引用热更程序集(XWorld.Framework.Shared,运行时才由 LoadDll 加载)
// 所以这里内联一份最小编解码,改协议时两边须同步。
public static class LocalCdnDiscovery
{
private const int Port = 48923; // = DevDiscovery.Port
private const uint ProtoVersion = 1; // = DevDiscovery.ProtoVersion
private static readonly byte[] Magic = { 0x58, 0x57, 0x44, 0x4C }; // "XWDL" = DevDiscovery.Magic
private const byte TypeQuery = 1; // = DevDiscovery.TypeQuery
private const byte TypeReply = 2; // = DevDiscovery.TypeReply
// 阻塞式发现,须在后台线程调用(会最多阻塞 durationMs)。
// 在窗口内周期重发查询(向广播 + 127.0.0.1 单播),收到首个合法应答即返回其 ResourceBaseUrl(CDN 根);超时返回 null。
// extraProbeIp:额外单播探测目标(跨网段联调用,来自 dev_server.txt 或弹框输入,见 DevServerConfig/DevServerPrompt)。
// 广播/定向广播不被路由器转发(RFC 2644),组播亦已实测不通(路由器未开组播路由),跨网段只能靠单播已知 IP。
// 注意:本方法跑在后台线程,不能碰 Unity 主线程 API(如 persistentDataPath),配置由调用方在主线程读好传入。
public static string Discover(string token, int durationMs = 1500, string extraProbeIp = null)
{
UdpClient udp = null;
try
{
udp = new UdpClient(AddressFamily.InterNetwork);
udp.EnableBroadcast = true;
udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
udp.Client.Bind(new IPEndPoint(IPAddress.Any, 0));
udp.Client.ReceiveTimeout = 250;
uint nonce = unchecked((uint)Guid.NewGuid().GetHashCode());
byte[] query = EncodeQuery(nonce, token);
// 同机广播常收不到(取决于网卡是否回环广播),故同时向 127.0.0.1 单播;并周期重发防网关刚起未监听。
// 255.255.255.255(限制广播)在部分 Android WiFi 驱动上会被丢弃,故再按各网卡 IP|~mask 补发子网定向广播(如 192.168.1.255)。
List<IPEndPoint> targetList = new List<IPEndPoint>
{
new IPEndPoint(IPAddress.Broadcast, Port),
new IPEndPoint(IPAddress.Loopback, Port),
};
foreach (IPAddress bc in GetDirectedBroadcasts())
targetList.Add(new IPEndPoint(bc, Port));
if (!string.IsNullOrEmpty(extraProbeIp) && IPAddress.TryParse(extraProbeIp, out IPAddress extra))
targetList.Add(new IPEndPoint(extra, Port));
IPEndPoint[] targets = targetList.ToArray();
UnityEngine.Debug.Log("[Discovery] targets(" + targets.Length + "): " + string.Join(", ", Array.ConvertAll(targets, t => t.ToString())));
DateTime deadline = DateTime.UtcNow.AddMilliseconds(durationMs);
DateTime nextSend = DateTime.MinValue;
while (DateTime.UtcNow < deadline)
{
if (DateTime.UtcNow >= nextSend)
{
foreach (IPEndPoint t in targets)
{
try { udp.Send(query, query.Length, t); } catch { }
}
nextSend = DateTime.UtcNow.AddMilliseconds(300);
}
IPEndPoint from = new IPEndPoint(IPAddress.Any, 0);
byte[] data;
try { data = udp.Receive(ref from); }
catch (SocketException) { continue; } // 250ms 接收超时,继续重发/等待
if (TryDecodeReply(data, nonce, token, out string resourceBaseUrl)
&& !string.IsNullOrEmpty(resourceBaseUrl))
{
return resourceBaseUrl;
}
}
}
catch (Exception)
{
// 忽略:发现失败由调用方处理(弹框输入或回退 127.0.0.1)
}
finally
{
try { udp?.Close(); } catch { }
}
return null;
}
// 枚举本机所有 Up 状态 IPv4 网卡,按 broadcast = ip | ~mask 算出各自的子网定向广播地址。
// 关键:Android/Mono 下 IPv4Mask 常拿不到(返回 null/0.0.0.0 甚至抛异常),故对私有网段按 /24 兜底,
// 且逐地址 try/catch,任何一块网卡取掩码失败都不中断整体枚举(否则会连累外层 return null)。
private static List<IPAddress> GetDirectedBroadcasts()
{
List<IPAddress> list = new List<IPAddress>();
NetworkInterface[] nics;
try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
catch (Exception e) { UnityEngine.Debug.LogWarning("[Discovery] GetAllNetworkInterfaces failed: " + e.Message); return list; }
foreach (NetworkInterface ni in nics)
{
if (ni.OperationalStatus != OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
IPInterfaceProperties props;
try { props = ni.GetIPProperties(); }
catch { continue; }
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
{
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue; // 只处理 IPv4
byte[] ip = ua.Address.GetAddressBytes();
if (ip.Length != 4) continue;
byte[] mk = null;
try { IPAddress m = ua.IPv4Mask; if (m != null) mk = m.GetAddressBytes(); }
catch { mk = null; } // Android 上访问 IPv4Mask 可能抛,兜住
// 掩码不可用(null/长度不对/全 0)时,私有网段按 /24 兜底(最常见的家用/办公 WiFi),公网地址不猜。
bool maskBad = mk == null || mk.Length != 4 || (mk[0] == 0 && mk[1] == 0 && mk[2] == 0 && mk[3] == 0);
if (maskBad)
{
if (!IsPrivateV4(ip)) continue;
mk = new byte[] { 255, 255, 255, 0 };
}
byte[] bc = new byte[4];
for (int i = 0; i < 4; i++) bc[i] = (byte)(ip[i] | (~mk[i] & 0xFF));
IPAddress bcAddr = new IPAddress(bc);
list.Add(bcAddr);
UnityEngine.Debug.Log("[Discovery] nic=" + ni.Name + " ip=" + ua.Address + " mask=" + string.Join(".", mk) + " bc=" + bcAddr);
}
}
return list;
}
private static bool IsPrivateV4(byte[] ip)
{
if (ip[0] == 10) return true; // 10.0.0.0/8
if (ip[0] == 192 && ip[1] == 168) return true; // 192.168.0.0/16
if (ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31) return true; // 172.16.0.0/12
return false;
}
private static byte[] EncodeQuery(uint nonce, string token)
{
var buf = new List<byte>(32);
for (int i = 0; i < Magic.Length; i++) buf.Add(Magic[i]);
buf.Add(TypeQuery);
WriteVarUInt(buf, ProtoVersion);
WriteVarUInt(buf, nonce);
WriteString(buf, token);
return buf.ToArray();
}
private static bool TryDecodeReply(byte[] data, uint expectNonce, string expectToken, out string resourceBaseUrl)
{
resourceBaseUrl = null;
try
{
int pos = 0;
for (int i = 0; i < Magic.Length; i++)
if (ReadByte(data, ref pos) != Magic[i]) return false;
if (ReadByte(data, ref pos) != TypeReply) return false;
if (ReadVarUInt(data, ref pos) != ProtoVersion) return false;
if (ReadVarUInt(data, ref pos) != expectNonce) return false;
ReadString(data, ref pos); // ServerName(忽略)
ReadString(data, ref pos); // GatewayUrl(忽略,LoadDll 只要 CDN)
string resBase = ReadString(data, ref pos); // ResourceBaseUrl(CDN 根)
string token = ReadString(data, ref pos);
if (token != expectToken) return false;
resourceBaseUrl = resBase;
return true;
}
catch (FormatException)
{
return false;
}
}
// ---- LEB128 varuint + UTF8 长度前缀字符串:与 PacketWriter/PacketReader 对齐 ----
private static void WriteVarUInt(List<byte> buf, uint v)
{
while (v >= 0x80) { buf.Add((byte)(v | 0x80)); v >>= 7; }
buf.Add((byte)v);
}
private static void WriteString(List<byte> buf, string s)
{
if (string.IsNullOrEmpty(s)) { WriteVarUInt(buf, 0); return; }
byte[] b = Encoding.UTF8.GetBytes(s);
WriteVarUInt(buf, (uint)b.Length);
buf.AddRange(b);
}
private static byte ReadByte(byte[] d, ref int pos)
{
if (pos >= d.Length) throw new FormatException("read past end");
return d[pos++];
}
private static uint ReadVarUInt(byte[] d, ref int pos)
{
uint result = 0;
int shift = 0;
while (true)
{
if (shift > 28) throw new FormatException("varuint too long");
byte b = ReadByte(d, ref pos);
result |= (uint)(b & 0x7F) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
}
return result;
}
private static string ReadString(byte[] d, ref int pos)
{
uint len = ReadVarUInt(d, ref pos);
if (len == 0) return string.Empty;
if (pos + len > d.Length) throw new FormatException("string length exceeds buffer");
string s = Encoding.UTF8.GetString(d, pos, (int)len);
pos += (int)len;
return s;
}
}