using System; using System.Collections.Generic; using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Threading; using UnityEngine; using XWorld.Framework.Protocol; namespace XGame.MiniGame { public sealed class DevServerDiscovery { private readonly string _token; private readonly string _extraProbeIp; private readonly object _lock = new object(); private readonly List _results = new List(); private Thread _thread; private volatile bool _running; public DevServerDiscovery(string token) { _token = token; // 跨网段联调:广播/定向广播不被路由器转发(RFC 2644),组播亦实测不通(路由器未开组播路由), // 唯一可靠路径是向已知开发机 IP 单播。IP 来自 persistentDataPath/dev_server.txt——底包首启弹框 // 录入(见 Base 层 DevServerConfig/DevServerPrompt)。persistentDataPath 不能在后台线程访问, // 故在主线程构造时读好存字段,Run(后台线程)只用字段。 _extraProbeIp = ReadConfiguredProbeIp(); } public bool IsRunning => _running; public void Begin(int durationMs = 1200) { if (_running) return; _running = true; _thread = new Thread(() => Run(durationMs)) { IsBackground = true }; _thread.Start(); } private void Run(int durationMs) { 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 = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery { ProtoVersion = DevDiscovery.ProtoVersion, Nonce = nonce, Token = _token, }); // 同机的 UDP 广播常收不到(取决于网卡是否回环广播),故同时向 127.0.0.1 单播; // 并在窗口内周期重发,防响应器(网关)刚起来还没监听。 // 255.255.255.255(限制广播)在部分 Android WiFi 驱动上会被丢弃,故再按各网卡 IP|~mask 补发子网定向广播(如 192.168.1.255)。 List targetList = new List { new IPEndPoint(IPAddress.Broadcast, DevDiscovery.Port), new IPEndPoint(IPAddress.Loopback, DevDiscovery.Port), }; foreach (IPAddress bc in GetDirectedBroadcasts()) targetList.Add(new IPEndPoint(bc, DevDiscovery.Port)); if (!string.IsNullOrEmpty(_extraProbeIp) && IPAddress.TryParse(_extraProbeIp, out IPAddress extra)) targetList.Add(new IPEndPoint(extra, DevDiscovery.Port)); IPEndPoint[] targets = targetList.ToArray(); Debug.Log("[DevTest] targets(" + targets.Length + "): " + string.Join(", ", Array.ConvertAll(targets, t => t.ToString()))); DateTime deadline = DateTime.UtcNow.AddMilliseconds(durationMs); DateTime nextSend = DateTime.MinValue; while (_running && 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; } if (!DevDiscoveryCodec.TryDecodeReply(data, out var reply)) continue; if (reply.ProtoVersion != DevDiscovery.ProtoVersion) continue; if (reply.Nonce != nonce) continue; if (reply.Token != _token) continue; lock (_lock) { if (!_results.Exists(r => r.GatewayUrl == reply.GatewayUrl)) { _results.Add(reply); } } } } catch (Exception e) { Debug.LogWarning("[DevTest] discovery thread error: " + e.Message); } finally { try { udp?.Close(); } catch { } _running = false; } } // 枚举本机所有 Up 状态 IPv4 网卡,按 broadcast = ip | ~mask 算出各自的子网定向广播地址。 // 关键:Android/Mono 下 IPv4Mask 常拿不到(返回 null/0.0.0.0 甚至抛异常),故对私有网段按 /24 兜底, // 且逐地址 try/catch,任何一块网卡取掩码失败都不中断整体枚举。 private static List GetDirectedBroadcasts() { List list = new List(); NetworkInterface[] nics; try { nics = NetworkInterface.GetAllNetworkInterfaces(); } catch (Exception e) { Debug.LogWarning("[DevTest] 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); Debug.Log("[DevTest] 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; } public List TakeResults() { lock (_lock) { return new List(_results); } } public void Stop() { _running = false; } // 读 dev_server.txt 首行并校验为 IPv4,失败一律 null。与 Base 层 DevServerConfig.Load() 语义一致—— // XWorld.Link 未引用 Base 程序集,按本项目“内联+两边同步”惯例复制最小实现,改格式时两边须同步。 private static string ReadConfiguredProbeIp() { try { string path = System.IO.Path.Combine(Application.persistentDataPath, "dev_server.txt"); if (!System.IO.File.Exists(path)) return null; string line = System.IO.File.ReadAllText(path).Trim(); if (line.Length == 0) return null; if (!IPAddress.TryParse(line, out IPAddress ip)) return null; if (ip.AddressFamily != AddressFamily.InterNetwork) return null; // 只认 IPv4 return ip.ToString(); } catch (Exception) { return null; } } } }