using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text.Json; using Microsoft.Extensions.Logging; using PSLauncher.Core.Lan; using PSLauncher.Core.Security; using PSLauncher.Models; namespace PSLauncher.Core.Manifests; public sealed class ManifestService : IManifestService { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; private readonly HttpClient _http; private readonly Func _serverBaseUrlProvider; private readonly Func _channelProvider; private readonly IManifestCache _manifestCache; private readonly IPeerManifestFetcher _peerManifestFetcher; private readonly ISettingsLockService _settingsLock; private readonly ILogger _logger; public ManifestSource LastSource { get; private set; } = ManifestSource.None; public ManifestService( HttpClient http, Func serverBaseUrlProvider, Func channelProvider, IManifestCache manifestCache, IPeerManifestFetcher peerManifestFetcher, ISettingsLockService settingsLock, ILogger logger) { _http = http; _serverBaseUrlProvider = serverBaseUrlProvider; _channelProvider = channelProvider; _manifestCache = manifestCache; _peerManifestFetcher = peerManifestFetcher; _settingsLock = settingsLock; _logger = logger; } /// /// No-op depuis v0.28 : le hash settings-lock vient maintenant de la /// LicenseValidationResponse (per-license). Voir /// et /// pour la /// propagation effective vers . /// private void PropagateSettingsLock(RemoteManifest? manifest) { _ = manifest; _ = _settingsLock; } /// /// Timeout court sur la tentative OVH : si le serveur Internet ne répond pas /// rapidement, on bascule sur les fallbacks LAN/cache plutôt que d'attendre les /// 100s du HttpClient.Timeout par défaut. Avec la race OVH+peer, ce timeout /// devient juste une borne haute — en pratique le peer répond en <200ms si /// dispo, OVH gagne la course en online normal. /// private const int OvhFetchTimeoutMs = 3_000; /// /// Stratégie : on probe la connectivité Internet d'abord (~10ms si online). /// - Online → OVH seul (source canonique, garantit de voir la version la plus /// récente même si nos peers ont un manifest plus ancien). Fallback cache /// disque si OVH plante. /// - Offline → peer LAN seul (pas la peine de payer le timeout OVH puisqu'on /// sait que ça va échouer). Fallback cache disque si pas de peer. /// /// Le test de connectivité combine ICMP ping (rapide) + TCP connect au serveur /// (au cas où le firewall corporate bloque ICMP) pour minimiser les faux négatifs. /// public async Task FetchAsync(CancellationToken ct) { var isOnline = await IsOnlineAsync(ct).ConfigureAwait(false); _logger.LogInformation("Fetching manifest (online={Online})", isOnline); if (isOnline) { // En ligne : OVH canonique, jamais de peer (qui pourrait avoir un manifest // plus ancien et masquer la dernière version publiée). try { var rawJson = await FetchFromOvhAsync(ct).ConfigureAwait(false); var manifest = JsonSerializer.Deserialize(rawJson, JsonOptions) ?? throw new InvalidOperationException("Empty manifest"); await _manifestCache.SaveAsync(rawJson, ct).ConfigureAwait(false); LastSource = ManifestSource.Ovh; PropagateSettingsLock(manifest); return manifest; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { _logger.LogWarning("OVH manifest fetch failed despite online connectivity ({Reason}), falling back to disk cache", ex.Message); var cached = await _manifestCache.TryLoadAsync(ct).ConfigureAwait(false); if (cached is not null) { LastSource = ManifestSource.DiskCache; PropagateSettingsLock(cached); return cached; } throw; } } // Offline : peer LAN direct, pas d'attente du timeout OVH. var peerJson = await _peerManifestFetcher.TryFetchRawAsync(ct).ConfigureAwait(false); if (peerJson is not null) { var manifest = JsonSerializer.Deserialize(peerJson, JsonOptions) ?? throw new InvalidOperationException("Empty peer manifest"); _logger.LogInformation("Manifest récupéré via peer LAN (offline)"); // Cache aussi le peer manifest pour servir aux autres clients du LAN. await _manifestCache.SaveAsync(peerJson, ct).ConfigureAwait(false); LastSource = ManifestSource.Peer; PropagateSettingsLock(manifest); return manifest; } // Aucun peer atteignable : on tente le cache disque local var localCache = await _manifestCache.TryLoadAsync(ct).ConfigureAwait(false); if (localCache is not null) { _logger.LogInformation("Manifest récupéré depuis le cache disque (offline + pas de peer)"); LastSource = ManifestSource.DiskCache; PropagateSettingsLock(localCache); return localCache; } throw new InvalidOperationException("Manifest unavailable: offline, no LAN peer responded, no local cache."); } private async Task FetchFromOvhAsync(CancellationToken ct) { // Channel = manifest filter par-license. NULL ou vide = on appelle // /manifest tout court (= versions.json default). Sinon /manifest?channel=X // (le serveur charge versions-{X}.json avec fallback sur default si fichier absent). // Whitelist côté client aussi pour éviter des chars exotiques qui casseraient l'URL. var channel = _channelProvider(); var baseUrl = TrimSlash(_serverBaseUrlProvider()) + "/manifest"; // Query params : ?channel=X (filtrage par channel license) + &multiChannel=1 // (opt-in au comportement v1.0.10+ : le serveur envoie plusieurs entries au // même numéro de version sans dédup, le client sait afficher chacune sur // sa row). Toujours envoyé — les serveurs anciens l'ignorent silencieusement. var qs = new System.Text.StringBuilder("?multiChannel=1"); if (!string.IsNullOrWhiteSpace(channel) && System.Text.RegularExpressions.Regex.IsMatch(channel!, "^[a-z0-9_-]{1,64}$")) { qs.Append("&channel=").Append(Uri.EscapeDataString(channel!)); } var url = baseUrl + qs.ToString(); using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct); ovhCts.CancelAfter(OvhFetchTimeoutMs); using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true, NoStore = true, }; req.Headers.Pragma.ParseAdd("no-cache"); using var resp = await _http.SendAsync(req, ovhCts.Token).ConfigureAwait(false); resp.EnsureSuccessStatusCode(); return await resp.Content.ReadAsStringAsync(ovhCts.Token).ConfigureAwait(false); } /// /// Détection rapide de connectivité Internet : 1) ping ICMP 8.8.8.8 (DNS Google, /// 99% reachable depuis n'importe quel LAN), 2) si ping ne marche pas (firewall /// corporate qui bloque ICMP), TCP connect direct sur le port HTTPS du serveur OVH. /// Online normal : ~10-30ms (premier ping suffit). Offline : ~1.5s pire cas /// (les deux probes timeout). Bon compromis vs un OVH HTTP fetch qui prend 3s. /// private async Task IsOnlineAsync(CancellationToken ct) { // Test 1 : ping ICMP (rapide ~10ms si online, fail-fast si offline) try { using var ping = new Ping(); var reply = await ping.SendPingAsync(IPAddress.Parse("8.8.8.8"), TimeSpan.FromMilliseconds(800)).ConfigureAwait(false); if (reply.Status == IPStatus.Success) { _logger.LogDebug("Online: ping 8.8.8.8 OK ({Ms} ms)", reply.RoundtripTime); return true; } } catch (Exception ex) { _logger.LogDebug(ex, "Ping 8.8.8.8 raised exception (firewall blocks ICMP ?)"); } // Test 2 : TCP connect direct sur le port HTTPS du serveur OVH. Cas du // firewall corporate qui bloque ICMP mais laisse passer HTTPS. try { if (Uri.TryCreate(_serverBaseUrlProvider(), UriKind.Absolute, out var serverUri)) { using var tcp = new TcpClient(); using var tcpCts = CancellationTokenSource.CreateLinkedTokenSource(ct); tcpCts.CancelAfter(TimeSpan.FromMilliseconds(800)); await tcp.ConnectAsync(serverUri.Host, serverUri.Port == -1 ? 443 : serverUri.Port, tcpCts.Token).ConfigureAwait(false); _logger.LogDebug("Online: TCP connect to {Host}:443 OK", serverUri.Host); return true; } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { _logger.LogDebug(ex, "TCP connect to server failed"); } _logger.LogInformation("Offline detected (ping + TCP both failed)"); return false; } public async Task FetchReleaseNotesAsync(string url, CancellationToken ct) { _logger.LogInformation("Fetching release notes: {Url}", url); // Timeout court : les release notes sont du markdown léger, et si OVH ne // répond pas, on aime mieux ouvrir le dialog d'install rapidement avec // "release notes indisponibles" plutôt que de bloquer la UI 100s. using var rnCts = CancellationTokenSource.CreateLinkedTokenSource(ct); rnCts.CancelAfter(OvhFetchTimeoutMs); using var resp = await _http.GetAsync(url, rnCts.Token).ConfigureAwait(false); resp.EnsureSuccessStatusCode(); return await resp.Content.ReadAsStringAsync(rnCts.Token).ConfigureAwait(false); } private static string TrimSlash(string s) => s.TrimEnd('/'); }