using System.Collections.Concurrent; using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using PSLauncher.Models; namespace PSLauncher.Core.Lan; public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryService { private const int BeaconIntervalMs = 15_000; private const int PeerTtlSeconds = 60; private readonly Func _configProvider; private readonly IZipCacheStore _cache; private readonly ILogger _logger; private readonly ConcurrentDictionary _peers = new(StringComparer.OrdinalIgnoreCase); private string? _ourLanIp; public LanDiscoveryService( Func configProvider, IZipCacheStore cache, ILogger logger) { _configProvider = configProvider; _cache = cache; _logger = logger; } public IReadOnlyList ListDiscovered() { var cutoff = DateTime.UtcNow.AddSeconds(-PeerTtlSeconds); return _peers.Values.Where(p => p.LastSeenUtc >= cutoff).ToList(); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var cfg = _configProvider(); if (!cfg.ServerEnabled && !cfg.ClientEnabled) { _logger.LogInformation("LAN discovery disabled (both ServerEnabled and ClientEnabled are false)"); return; } _ourLanIp = GetLocalLanIp(); // Listener (toujours actif si Client OU Server activé) UdpClient? listener = null; try { listener = new UdpClient(AddressFamily.InterNetwork); listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Client.Bind(new IPEndPoint(IPAddress.Any, cfg.DiscoveryPort)); listener.EnableBroadcast = true; _logger.LogInformation("LAN discovery listening on UDP :{Port}", cfg.DiscoveryPort); } catch (Exception ex) { _logger.LogError(ex, "Failed to bind UDP discovery socket on port {Port}", cfg.DiscoveryPort); listener?.Dispose(); return; } var listenTask = ListenLoopAsync(listener, stoppingToken); // Broadcaster (uniquement si Server activé) Task? broadcastTask = null; if (cfg.ServerEnabled) { broadcastTask = BroadcastLoopAsync(cfg.DiscoveryPort, cfg.ServerPort, stoppingToken); } try { await Task.WhenAll(broadcastTask is null ? new[] { listenTask } : new[] { listenTask, broadcastTask }) .ConfigureAwait(false); } catch (OperationCanceledException) { /* expected on shutdown */ } finally { try { listener.Close(); } catch { } listener.Dispose(); } } private async Task ListenLoopAsync(UdpClient listener, CancellationToken ct) { while (!ct.IsCancellationRequested) { try { var result = await listener.ReceiveAsync(ct).ConfigureAwait(false); var senderHost = result.RemoteEndPoint.Address.ToString(); // Ignore nos propres beacons if (_ourLanIp is not null && string.Equals(senderHost, _ourLanIp, StringComparison.OrdinalIgnoreCase)) continue; var json = Encoding.UTF8.GetString(result.Buffer); var beacon = JsonSerializer.Deserialize(json); if (beacon is null || beacon.V != 1) continue; if (string.IsNullOrEmpty(beacon.Host) || beacon.Port <= 0 || beacon.Port > 65535) continue; var peer = new DiscoveredPeer( Host: beacon.Host, Port: beacon.Port, Versions: (IReadOnlyList?)beacon.Versions ?? Array.Empty(), LastSeenUtc: DateTime.UtcNow); _peers[beacon.Host] = peer; } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogDebug(ex, "Discovery listen iteration failed (will retry)"); try { await Task.Delay(500, ct); } catch { break; } } } } private async Task BroadcastLoopAsync(int discoveryPort, int httpPort, CancellationToken ct) { using var sender = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true }; var endpoint = new IPEndPoint(IPAddress.Broadcast, discoveryPort); while (!ct.IsCancellationRequested) { try { var versions = _cache.ListCached().Select(c => c.Version).ToList(); var host = _ourLanIp ?? GetLocalLanIp() ?? "0.0.0.0"; var payload = new BeaconPayload { V = 1, Host = host, Port = httpPort, Versions = versions, }; var json = JsonSerializer.Serialize(payload); var bytes = Encoding.UTF8.GetBytes(json); await sender.SendAsync(bytes, bytes.Length, endpoint).ConfigureAwait(false); } catch (Exception ex) { _logger.LogDebug(ex, "Discovery broadcast failed (will retry)"); } try { await Task.Delay(BeaconIntervalMs, ct); } catch { break; } } } /// /// Renvoie l'IPv4 LAN du PC : interface UP non-loopback avec une default /// gateway IPv4. Filtre Hyper-V virtual switch, VPN sans gateway, etc. /// Same logic that MainViewModel.LocalIpAddress uses for the sidebar info. /// public static string? GetLocalLanIp() { try { return NetworkInterface.GetAllNetworkInterfaces() .Where(n => n.OperationalStatus == OperationalStatus.Up && n.NetworkInterfaceType != NetworkInterfaceType.Loopback && n.GetIPProperties().GatewayAddresses .Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork)) .SelectMany(n => n.GetIPProperties().UnicastAddresses) .Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork) .Select(a => a.Address.ToString()) .FirstOrDefault(); } catch { return null; } } private sealed class BeaconPayload { [JsonPropertyName("v")] public int V { get; set; } [JsonPropertyName("host")] public string Host { get; set; } = ""; [JsonPropertyName("port")] public int Port { get; set; } [JsonPropertyName("versions")] public List? Versions { get; set; } } }