From 3d691c3e381f70bf609ac2e0b52801d3a0970129 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Mon, 4 May 2026 17:51:50 +0200 Subject: [PATCH] =?UTF-8?q?v0.25.10=20=E2=80=94=20LAN=20P2P=20offline-frie?= =?UTF-8?q?ndly=20:=20manifest=20fallback=20+=20perf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fonctionne entièrement offline si un PC du LAN a fetché OVH récemment : les autres PCs récupèrent le manifest + les ZIPs depuis lui sans Internet. Discovery des peers quasi-instantanée au cold start, fail-fast partout sur les fetch OVH pour ne pas bloquer la UI. == Manifest fallback offline == - IManifestCache + ManifestCache (Core/Manifests/) : cache disque du dernier manifest réussi (JSON brut byte-for-byte pour préserver la signature Ed25519). Path : %LocalAppData%\PSLauncher\manifest-cache.json - IPeerManifestFetcher + PeerManifestFetcher (Core/Lan/) : récupère le manifest brut depuis un peer LAN via GET /v1/manifest. Probe parallèle avec timeout court par peer. - LanCacheServer : nouvelle route GET /v1/manifest qui sert le cache disque byte-for-byte aux clients sans Internet. - ManifestService.FetchAsync : online check d'abord (ping ICMP 8.8.8.8 + fallback TCP connect au serveur OVH si firewall bloque ICMP, timeout 800ms × 2). Si online → OVH only (canonique, voit toutes les versions). Si offline → peer LAN puis cache disque local. - ManifestSource enum exposé via IManifestService.LastSource → call sites peuvent prendre des décisions éclairées (skip release notes, badge UI). == Discovery active des peers (cold start instantané) == - LanCacheServer : nouvelle route GET /v1/info qui retourne {host, port, versions} (même payload que les beacons UDP). - LanDiscoveryService : bootstrap au démarrage qui probe en parallèle tous les peers connus (cache disque) + manuels (config) via /v1/info, timeout 1s par peer. Les peers qui répondent sont injectés dans _peers avec lastSeen=now → apparaissent immédiatement dans ListDiscovered() et donc dans la sidebar + PeerSourceResolver. Plus besoin d'attendre les 15s du prochain beacon UDP. - ILanDiscoveryService.ListKnown() exposé pour que les fetchers puissent utiliser le cache disque comme seed. - LocalConfig.LanCache.ManualPeerUrls : default ["http://10.0.4.100:47623"] (IP fixe du serveur ASTERION dans toutes les salles VR). == Timeouts courts sur tous les fetch OVH (fail-fast offline) == - ManifestService : 3s pour le manifest fetch (vs 100s par défaut) - LicenseService.ValidateAsync : 3s (résolvait le délai de ~15s pendant CheckForUpdates en offline — HttpClient global a Timeout=Infinite, sans CTS court ça hangait jusqu'à l'abandon SYN TCP par l'OS) - LicenseService.GetSignedDownloadUrlAsync : 5s - ManifestService.FetchReleaseNotesAsync : 5s == UI == - StatusMessage post-Check Updates suffixé avec la source du manifest : "🌐 OVH (en ligne)" / "📡 Peer LAN (hors ligne)" / "💾 Cache local (hors ligne)" - InstallVersionAsync : skip le fetch release notes si manifest source != OVH → dialog s'ouvre instantanément avec "indisponible" au lieu d'attendre 5s de timeout. == Latences attendues == | Scénario | Avant | Après | |---|---|---| | Vérifier MAJ online | ~5s | ~500ms | | Vérifier MAJ offline | ~15-20s | ~5s | | Cold start discovery (1 peer connu) | jusqu'à 15s | ~200ms | | Click Install offline | ~5s release notes timeout | instantané | Co-Authored-By: Claude Opus 4.7 (1M context) --- installer/PSLauncher.iss | 2 +- src/PSLauncher.App/App.xaml.cs | 11 ++ src/PSLauncher.App/PSLauncher.App.csproj | 6 +- .../ViewModels/MainViewModel.cs | 30 ++- .../Lan/ILanDiscoveryService.cs | 7 + .../Lan/IPeerManifestFetcher.cs | 21 ++ .../Lan/IPeerSourceResolver.cs | 37 ++++ src/PSLauncher.Core/Lan/LanCacheServer.cs | 62 ++++++ .../Lan/LanDiscoveryService.cs | 182 ++++++++++++++++++ .../Lan/PeerManifestFetcher.cs | 75 ++++++++ src/PSLauncher.Core/Lan/PeerSourceResolver.cs | 22 +-- .../Licensing/LicenseService.cs | 25 ++- src/PSLauncher.Core/Localization/Strings.cs | 5 + .../Manifests/IManifestCache.cs | 26 +++ .../Manifests/IManifestService.cs | 18 ++ .../Manifests/ManifestCache.cs | 61 ++++++ .../Manifests/ManifestService.cs | 163 +++++++++++++++- src/PSLauncher.Models/LocalConfig.cs | 6 +- 18 files changed, 715 insertions(+), 44 deletions(-) create mode 100644 src/PSLauncher.Core/Lan/IPeerManifestFetcher.cs create mode 100644 src/PSLauncher.Core/Lan/PeerManifestFetcher.cs create mode 100644 src/PSLauncher.Core/Manifests/IManifestCache.cs create mode 100644 src/PSLauncher.Core/Manifests/ManifestCache.cs diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 28d2ff4..7544b70 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.25.4" +#define MyAppVersion "0.25.10" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index 34318c3..7b3f6f5 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -174,10 +174,13 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => new ManifestService( sp.GetRequiredService(), () => sp.GetRequiredService().ServerBaseUrl, + sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService>())); services.AddSingleton(); @@ -237,12 +240,15 @@ public partial class App : Application services.AddSingleton(sp => new LanDiscoveryService( configProvider: () => sp.GetRequiredService().LanCache, cache: sp.GetRequiredService(), + configStore: sp.GetRequiredService(), + http: sp.GetRequiredService(), logger: sp.GetRequiredService>())); services.AddSingleton(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); services.AddSingleton(sp => new LanCacheServer( configProvider: () => sp.GetRequiredService().LanCache, cache: sp.GetRequiredService(), + manifestCache: sp.GetRequiredService(), logger: sp.GetRequiredService>())); services.AddSingleton(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); @@ -251,6 +257,11 @@ public partial class App : Application discovery: sp.GetRequiredService(), http: sp.GetRequiredService(), logger: sp.GetRequiredService>())); + services.AddSingleton(sp => new PeerManifestFetcher( + configProvider: () => sp.GetRequiredService().LanCache, + discovery: sp.GetRequiredService(), + http: sp.GetRequiredService(), + logger: sp.GetRequiredService>())); services.AddSingleton(); services.AddSingleton(); diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index ceccd71..244e6b0 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -18,9 +18,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.25.4 - 0.25.4.0 - 0.25.4.0 + 0.25.10 + 0.25.10.0 + 0.25.10.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 69ed342..34ee65b 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -656,12 +656,23 @@ public sealed partial class MainViewModel : ObservableObject _ = Application.Current.Dispatcher.BeginInvoke(() => PromptLauncherUpdate(launcher)); } + // Suffixe d'origine du manifest pour que l'utilisateur sache si la + // liste des versions vient d'OVH (canonique, à jour) ou d'un peer/cache + // local (potentiellement plus ancien). + var sourceSuffix = _manifestService.LastSource switch + { + ManifestSource.Ovh => " · " + Strings.ManifestSourceOvh, + ManifestSource.Peer => " · " + Strings.ManifestSourcePeer, + ManifestSource.DiskCache => " · " + Strings.ManifestSourceDiskCache, + _ => string.Empty, + }; + if (result.IsLatestNewerThanInstalled && result.LatestRemote is not null) - StatusMessage = Strings.StatusNewAvailable(result.LatestRemote.Version); + StatusMessage = Strings.StatusNewAvailable(result.LatestRemote.Version) + sourceSuffix; else if (result.LatestRemote is not null) - StatusMessage = Strings.StatusUpToDate(result.LatestRemote.Version); + StatusMessage = Strings.StatusUpToDate(result.LatestRemote.Version) + sourceSuffix; else - StatusMessage = Strings.StatusNoRemote; + StatusMessage = Strings.StatusNoRemote + sourceSuffix; } catch (Exception ex) { @@ -941,9 +952,13 @@ public sealed partial class MainViewModel : ObservableObject if (!isResume) { - // 1) Récupère release notes (best effort) + // 1) Récupère release notes (best effort). + // Skip si on a fetché le manifest depuis un peer ou cache (= probablement + // offline) : l'URL release notes pointe sur OVH et le fetch va timeout + // bêtement. Mieux vaut ouvrir le dialog tout de suite avec "indisponible". string notes = Strings.ReleaseNotesNone; - if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl)) + var manifestFromOvh = _manifestService.LastSource == ManifestSource.Ovh; + if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl) && manifestFromOvh) { try { @@ -951,6 +966,11 @@ public sealed partial class MainViewModel : ObservableObject } catch (Exception ex) { _logger.LogWarning(ex, "Release notes fetch failed"); notes = Strings.ReleaseNotesUnavailable; } } + else if (!string.IsNullOrEmpty(row.Remote.ReleaseNotesUrl)) + { + _logger.LogInformation("Skipping release notes fetch (manifest source = {Source}, OVH probably unreachable)", _manifestService.LastSource); + notes = Strings.ReleaseNotesUnavailable; + } // 2) Dialog de confirmation avec release notes var dialog = new UpdateAvailableDialog(row.Remote, notes) { Owner = Application.Current.MainWindow }; diff --git a/src/PSLauncher.Core/Lan/ILanDiscoveryService.cs b/src/PSLauncher.Core/Lan/ILanDiscoveryService.cs index f58639d..57ca44b 100644 --- a/src/PSLauncher.Core/Lan/ILanDiscoveryService.cs +++ b/src/PSLauncher.Core/Lan/ILanDiscoveryService.cs @@ -27,4 +27,11 @@ public interface ILanDiscoveryService { /// Liste des peers vus récemment (lastSeen >= now - 60s). Vide si discovery désactivée. IReadOnlyList ListDiscovered(); + + /// + /// Peers connus de runs précédents, chargés depuis le cache disque au démarrage + /// (max 1 semaine). Utile en cold start pour éviter d'attendre les 15 s du + /// premier beacon UDP avant qu'un peer apparaisse. + /// + IReadOnlyList ListKnown(); } diff --git a/src/PSLauncher.Core/Lan/IPeerManifestFetcher.cs b/src/PSLauncher.Core/Lan/IPeerManifestFetcher.cs new file mode 100644 index 0000000..75a4dc7 --- /dev/null +++ b/src/PSLauncher.Core/Lan/IPeerManifestFetcher.cs @@ -0,0 +1,21 @@ +namespace PSLauncher.Core.Lan; + +/// +/// Récupère le manifest depuis un peer LAN. Sert quand le client n'a pas +/// d'accès Internet : il interroge les peers découverts via UDP discovery +/// (et les peers manuels en config) pour trouver un PC ayant déjà fetché le +/// manifest depuis OVH récemment. +/// +/// La sécurité reste celle du manifest signé Ed25519 — le peer pourrait +/// servir n'importe quel JSON, mais le launcher rejettera tout manifest non +/// signé correctement (ou tout ZIP dont le SHA-256 ne match pas). +/// +public interface IPeerManifestFetcher +{ + /// + /// Tente de récupérer le manifest brut (JSON, byte-for-byte) depuis le + /// premier peer qui répond. Null si aucun peer atteignable, no-op si + /// ClientEnabled=false. + /// + Task TryFetchRawAsync(CancellationToken ct); +} diff --git a/src/PSLauncher.Core/Lan/IPeerSourceResolver.cs b/src/PSLauncher.Core/Lan/IPeerSourceResolver.cs index 3c8e9bd..7bbaeb7 100644 --- a/src/PSLauncher.Core/Lan/IPeerSourceResolver.cs +++ b/src/PSLauncher.Core/Lan/IPeerSourceResolver.cs @@ -7,6 +7,43 @@ namespace PSLauncher.Core.Lan; /// public sealed record PeerSource(Uri ZipUrl, string PeerHost); +/// +/// Helper interne aux probers (PeerSourceResolver, PeerManifestFetcher) pour +/// fusionner peers actifs (beacons UDP récents), peers connus (cache disque), +/// peers manuels (config). Évite la duplication entre les 2 fetchers. +/// +internal static class PeerCandidates +{ + public static List Build(ILanDiscoveryService discovery, IEnumerable manualUrls) + { + var candidates = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + // 1) Peers actifs (UDP beacon < 60s) — les plus fiables + foreach (var p in discovery.ListDiscovered()) + { + var url = $"http://{p.Host}:{p.Port}"; + if (seen.Add(url)) candidates.Add(url); + } + // 2) Peers connus (cache disque, runs précédents) — essayés rapidement même + // si pas encore reçu de beacon ce run (cold start, < 15s d'uptime) + foreach (var p in discovery.ListKnown()) + { + var url = $"http://{p.Host}:{p.Port}"; + if (seen.Add(url)) candidates.Add(url); + } + // 3) Peers manuels (config user) — fallback / override explicite + foreach (var url in manualUrls) + { + var trimmed = url.Trim().TrimEnd('/'); + if (string.IsNullOrEmpty(trimmed)) continue; + if (seen.Add(trimmed)) candidates.Add(trimmed); + } + + return candidates; + } +} + /// /// Trouve un peer LAN qui a la version demandée. Combine peers découverts auto /// (UDP discovery) et peers manuels (config), probe chacun avec un timeout court, diff --git a/src/PSLauncher.Core/Lan/LanCacheServer.cs b/src/PSLauncher.Core/Lan/LanCacheServer.cs index d09584e..d292ea1 100644 --- a/src/PSLauncher.Core/Lan/LanCacheServer.cs +++ b/src/PSLauncher.Core/Lan/LanCacheServer.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using PSLauncher.Core.Manifests; using PSLauncher.Models; namespace PSLauncher.Core.Lan; @@ -35,6 +36,7 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer private readonly Func _configProvider; private readonly IZipCacheStore _cache; + private readonly IManifestCache _manifestCache; private readonly ILogger _logger; private readonly SemaphoreSlim _serveLimit = new(MaxConcurrentServes, MaxConcurrentServes); @@ -45,10 +47,12 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer public LanCacheServer( Func configProvider, IZipCacheStore cache, + IManifestCache manifestCache, ILogger logger) { _configProvider = configProvider; _cache = cache; + _manifestCache = manifestCache; _logger = logger; } @@ -151,6 +155,8 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer const string hasPrefix = "/v1/has/"; const string zipPrefix = "/v1/zip/"; + const string manifestPath = "/v1/manifest"; + const string infoPath = "/v1/info"; if (request.Path.StartsWith(hasPrefix, StringComparison.Ordinal)) { var version = request.Path.Substring(hasPrefix.Length); @@ -161,6 +167,14 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer var version = request.Path.Substring(zipPrefix.Length); await HandleZipAsync(stream, version, request.RangeHeader, ct).ConfigureAwait(false); } + else if (request.Path == manifestPath) + { + await HandleManifestAsync(stream, ct).ConfigureAwait(false); + } + else if (request.Path == infoPath) + { + await HandleInfoAsync(stream, ct).ConfigureAwait(false); + } else { await WriteSimpleResponseAsync(stream, 404, "not found", ct).ConfigureAwait(false); @@ -197,6 +211,54 @@ public sealed class LanCacheServer : BackgroundService, ILanCacheServer await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false); } + /// + /// Endpoint léger d'auto-discovery active. Retourne la même structure que + /// le beacon UDP : {v, host, port, versions}. Sert au cold-start + /// d'un client qui veut probe ses peers connus du cache disque ou manuels + /// SANS attendre les 15 s du prochain beacon UDP. + /// + private async Task HandleInfoAsync(NetworkStream stream, CancellationToken ct) + { + var versions = _cache.ListCached().Select(c => c.Version).ToList(); + var json = JsonSerializer.Serialize(new + { + v = 1, + host = (_boundEndpoints.Count > 0 ? _boundEndpoints[0] : $"http://0.0.0.0:{_boundPort}") + .Replace("http://", "").Split(':')[0], + port = _boundPort ?? 0, + versions, + }); + var body = Encoding.UTF8.GetBytes(json); + await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false); + } + + /// + /// Sert le manifest cached byte-for-byte aux peers du LAN qui n'ont pas + /// d'accès Internet. Le client validera la signature Ed25519 du manifest + /// (trust chain inchangée). 404 si pas de cache (le serveur PC n'a jamais + /// fetché OVH avec succès). + /// + private async Task HandleManifestAsync(NetworkStream stream, CancellationToken ct) + { + var path = _manifestCache.GetCachedFilePath(); + if (!File.Exists(path)) + { + await WriteSimpleResponseAsync(stream, 404, "no manifest cached", ct).ConfigureAwait(false); + return; + } + + try + { + var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false); + await WriteResponseAsync(stream, 200, "OK", "application/json", bytes.LongLength, null, bytes, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to serve cached manifest"); + await WriteSimpleResponseAsync(stream, 500, "manifest read error", ct).ConfigureAwait(false); + } + } + private async Task HandleZipAsync(NetworkStream stream, string version, string? rangeHeader, CancellationToken ct) { if (!VersionRegex.IsMatch(version)) diff --git a/src/PSLauncher.Core/Lan/LanDiscoveryService.cs b/src/PSLauncher.Core/Lan/LanDiscoveryService.cs index 2ee6e3d..8c8af71 100644 --- a/src/PSLauncher.Core/Lan/LanDiscoveryService.cs +++ b/src/PSLauncher.Core/Lan/LanDiscoveryService.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using PSLauncher.Core.Configuration; using PSLauncher.Models; namespace PSLauncher.Core.Lan; @@ -15,21 +16,35 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic { private const int BeaconIntervalMs = 15_000; private const int PeerTtlSeconds = 60; + /// TTL des peers persistés sur disque entre deux runs du launcher. + private const int KnownPeerStaleHours = 168; // 1 semaine + private const string KnownPeersFileName = "lan-peers.json"; private readonly Func _configProvider; private readonly IZipCacheStore _cache; + private readonly IConfigStore _configStore; + private readonly HttpClient _http; private readonly ILogger _logger; private readonly ConcurrentDictionary _peers = new(StringComparer.OrdinalIgnoreCase); + /// Peers pré-chargés depuis le disque au démarrage. Pas de TTL court — ils + /// servent de "seed" pour ne pas attendre les 15s du premier beacon UDP. + /// PeerSourceResolver / PeerManifestFetcher les utilisent en plus de ListDiscovered(). + private List _knownPeers = new(); private string? _ourLanIp; + private DateTime _lastDiskFlush = DateTime.MinValue; public LanDiscoveryService( Func configProvider, IZipCacheStore cache, + IConfigStore configStore, + HttpClient http, ILogger logger) { _configProvider = configProvider; _cache = cache; + _configStore = configStore; + _http = http; _logger = logger; } @@ -39,6 +54,14 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic return _peers.Values.Where(p => p.LastSeenUtc >= cutoff).ToList(); } + /// + /// Peers connus de runs précédents (persistés sur disque, max 1 semaine). + /// Sert de seed pour démarrer rapidement avant que le premier beacon arrive. + /// Probés en HTTP par PeerSourceResolver — si le peer est offline on aura juste + /// un timeout puis on essaiera le suivant. + /// + public IReadOnlyList ListKnown() => _knownPeers; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var cfg = _configProvider(); @@ -50,6 +73,19 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic _ourLanIp = GetLocalLanIp(); + // Charge les peers connus de la session précédente. Les probes pourront + // ainsi essayer ces IPs immédiatement, sans attendre les 15s du premier + // beacon UDP. Si un peer est offline, on aura juste un timeout HTTP rapide. + _knownPeers = LoadKnownPeers(); + if (_knownPeers.Count > 0) + _logger.LogInformation("Loaded {Count} known peers from disk cache", _knownPeers.Count); + + // BOOTSTRAP ACTIF : kick off un probe HTTP /v1/info en parallèle sur tous les + // peers connus (cache disque) + manuels (config). Découverte instantanée au + // lieu d'attendre les 15 s du prochain beacon UDP. Fire-and-forget : le reste + // du service démarre normalement pendant que le probe tourne en background. + _ = Task.Run(() => BootstrapProbeKnownPeersAsync(cfg, stoppingToken), stoppingToken); + // Listener (toujours actif si Client OU Server activé) UdpClient? listener = null; try @@ -113,6 +149,14 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic Versions: (IReadOnlyList?)beacon.Versions ?? Array.Empty(), LastSeenUtc: DateTime.UtcNow); _peers[beacon.Host] = peer; + + // Persist to disk au plus toutes les 30s pour ne pas spammer le SSD + // (un beacon arrive toutes les 15s par peer × N peers = beaucoup d'écritures). + if ((DateTime.UtcNow - _lastDiskFlush).TotalSeconds >= 30) + { + _lastDiskFlush = DateTime.UtcNow; + SaveKnownPeers(_peers.Values); + } } catch (OperationCanceledException) { break; } catch (Exception ex) @@ -182,4 +226,142 @@ public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryServic [JsonPropertyName("port")] public int Port { get; set; } [JsonPropertyName("versions")] public List? Versions { get; set; } } + + private sealed class StoredPeer + { + [JsonPropertyName("host")] public string Host { get; set; } = ""; + [JsonPropertyName("port")] public int Port { get; set; } + [JsonPropertyName("versions")] public List Versions { get; set; } = new(); + [JsonPropertyName("lastSeenUtc")] public DateTime LastSeenUtc { get; set; } + } + + private string GetKnownPeersPath() => + Path.Combine(_configStore.ConfigDirectory, KnownPeersFileName); + + private List LoadKnownPeers() + { + var path = GetKnownPeersPath(); + if (!File.Exists(path)) return new List(); + try + { + var raw = File.ReadAllText(path); + var stored = JsonSerializer.Deserialize>(raw); + if (stored is null) return new List(); + var staleAfter = DateTime.UtcNow.AddHours(-KnownPeerStaleHours); + return stored + .Where(s => s.LastSeenUtc >= staleAfter + && !string.IsNullOrEmpty(s.Host) + && s.Port > 0 && s.Port < 65536) + .Select(s => new DiscoveredPeer(s.Host, s.Port, s.Versions, s.LastSeenUtc)) + .ToList(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to load known peers cache (non-fatal)"); + return new List(); + } + } + + /// + /// Probe HTTP actif en parallèle de tous les peers connus (cache disque) et + /// manuels (config). Pour chaque peer qui répond à /v1/info, on l'injecte dans + /// _peers avec lastSeen=now → il apparaît immédiatement dans ListDiscovered() + /// et donc dans la sidebar + PeerSourceResolver. Aucune attente d'un beacon UDP. + /// Timeout court (1 s) par peer, parallel pour ne pas additionner. + /// + private async Task BootstrapProbeKnownPeersAsync(LanCacheConfig cfg, CancellationToken ct) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var urls = new List(); + foreach (var p in _knownPeers) + { + var u = $"http://{p.Host}:{p.Port}"; + if (seen.Add(u)) urls.Add(u); + } + foreach (var raw in cfg.ManualPeerUrls ?? new List()) + { + var t = raw.Trim().TrimEnd('/'); + if (string.IsNullOrEmpty(t)) continue; + if (seen.Add(t)) urls.Add(t); + } + + if (urls.Count == 0) + { + _logger.LogDebug("Bootstrap probe: no known/manual peers to probe"); + return; + } + + _logger.LogInformation("Bootstrap probe: attempting {Count} known/manual peers in parallel", urls.Count); + var tasks = urls.Select(u => ProbeOnePeerAsync(u, ct)).ToArray(); + await Task.WhenAll(tasks).ConfigureAwait(false); + + var alive = _peers.Count; + _logger.LogInformation("Bootstrap probe done: {Alive} peer(s) responding", alive); + if (alive > 0) + { + // Persist immediately — la session précédente avait peut-être des peers stale, + // on remet à jour avec ceux qui répondent vraiment maintenant. + SaveKnownPeers(_peers.Values); + _lastDiskFlush = DateTime.UtcNow; + } + } + + private async Task ProbeOnePeerAsync(string baseUrl, CancellationToken ct) + { + try + { + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + probeCts.CancelAfter(TimeSpan.FromSeconds(1)); + using var resp = await _http.GetAsync($"{baseUrl}/v1/info", probeCts.Token).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) return; + var json = await resp.Content.ReadAsStringAsync(probeCts.Token).ConfigureAwait(false); + var beacon = JsonSerializer.Deserialize(json); + if (beacon is null || string.IsNullOrEmpty(beacon.Host) || beacon.Port <= 0) return; + + // Ignore notre propre IP (au cas où une URL manuelle pointerait sur nous-mêmes) + if (_ourLanIp is not null && string.Equals(beacon.Host, _ourLanIp, StringComparison.OrdinalIgnoreCase)) + return; + + var peer = new DiscoveredPeer( + Host: beacon.Host, + Port: beacon.Port, + Versions: (IReadOnlyList?)beacon.Versions ?? Array.Empty(), + LastSeenUtc: DateTime.UtcNow); + _peers[beacon.Host] = peer; + _logger.LogInformation("Bootstrap probe: peer alive at {Url} (versions: {V})", baseUrl, + beacon.Versions is { Count: > 0 } ? string.Join(",", beacon.Versions) : "—"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* shutdown */ } + catch (Exception ex) + { + _logger.LogDebug("Bootstrap probe of {Url} failed: {Reason}", baseUrl, ex.Message); + } + } + + private void SaveKnownPeers(IEnumerable peers) + { + var path = GetKnownPeersPath(); + try + { + var stored = peers + .Where(p => !string.IsNullOrEmpty(p.Host)) + .Select(p => new StoredPeer + { + Host = p.Host, + Port = p.Port, + Versions = p.Versions.ToList(), + LastSeenUtc = p.LastSeenUtc, + }) + .ToList(); + var json = JsonSerializer.Serialize(stored); + // Atomic write : .tmp puis rename pour ne pas corrompre si crash mi-écriture. + var tmp = path + ".tmp"; + File.WriteAllText(tmp, json); + File.Move(tmp, path, overwrite: true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to save known peers cache (non-fatal)"); + } + } } diff --git a/src/PSLauncher.Core/Lan/PeerManifestFetcher.cs b/src/PSLauncher.Core/Lan/PeerManifestFetcher.cs new file mode 100644 index 0000000..068c50a --- /dev/null +++ b/src/PSLauncher.Core/Lan/PeerManifestFetcher.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Logging; +using PSLauncher.Models; + +namespace PSLauncher.Core.Lan; + +public sealed class PeerManifestFetcher : IPeerManifestFetcher +{ + private const int MaxPeersToProbe = 5; + + private readonly Func _configProvider; + private readonly ILanDiscoveryService _discovery; + private readonly HttpClient _http; + private readonly ILogger _logger; + + public PeerManifestFetcher( + Func configProvider, + ILanDiscoveryService discovery, + HttpClient http, + ILogger logger) + { + _configProvider = configProvider; + _discovery = discovery; + _http = http; + _logger = logger; + } + + public async Task TryFetchRawAsync(CancellationToken ct) + { + var cfg = _configProvider(); + if (!cfg.ClientEnabled) return null; + + // Liste fusionnée : actifs + connus (cache disque) + manuels. + var candidates = PeerCandidates.Build(_discovery, cfg.ManualPeerUrls); + + if (candidates.Count == 0) + { + _logger.LogDebug("No peers available for manifest fallback"); + return null; + } + + var probeMs = cfg.ProbeTimeoutMs > 0 ? cfg.ProbeTimeoutMs : 1000; + foreach (var baseUrl in candidates.Take(MaxPeersToProbe)) + { + ct.ThrowIfCancellationRequested(); + try + { + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + probeCts.CancelAfter(probeMs); + + var url = $"{baseUrl}/v1/manifest"; + using var resp = await _http.GetAsync(url, probeCts.Token).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + { + _logger.LogDebug("Peer {Url} manifest fetch returned {Status}", baseUrl, (int)resp.StatusCode); + continue; + } + var json = await resp.Content.ReadAsStringAsync(probeCts.Token).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(json)) continue; + _logger.LogInformation("Manifest fetched from peer {Url} ({Bytes} bytes)", baseUrl, json.Length); + return json; + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + _logger.LogDebug("Peer {Url} manifest probe timed out", baseUrl); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Peer {Url} manifest probe failed", baseUrl); + } + } + + _logger.LogInformation("No peer responded with a manifest"); + return null; + } +} diff --git a/src/PSLauncher.Core/Lan/PeerSourceResolver.cs b/src/PSLauncher.Core/Lan/PeerSourceResolver.cs index a8f17e6..3a08abb 100644 --- a/src/PSLauncher.Core/Lan/PeerSourceResolver.cs +++ b/src/PSLauncher.Core/Lan/PeerSourceResolver.cs @@ -38,25 +38,9 @@ public sealed class PeerSourceResolver : IPeerSourceResolver return null; } - // Construit la liste des base URLs à probe : auto-découverts d'abord - // (qui annoncent déjà avoir la version), puis manuels (qu'on probe à l'aveugle). - var candidates = new List(); - foreach (var p in _discovery.ListDiscovered()) - { - // Optim : si le peer annonce sa liste de versions et que la nôtre n'y est - // pas, skip. Économise un round-trip HTTP. Mais on probe quand même si - // la liste est vide (compat anciens peers / beacon partiel). - if (p.Versions.Count > 0 && !p.Versions.Contains(version, StringComparer.OrdinalIgnoreCase)) - continue; - candidates.Add($"http://{p.Host}:{p.Port}"); - } - foreach (var url in cfg.ManualPeerUrls) - { - var trimmed = url.Trim().TrimEnd('/'); - if (string.IsNullOrEmpty(trimmed)) continue; - if (!candidates.Contains(trimmed, StringComparer.OrdinalIgnoreCase)) - candidates.Add(trimmed); - } + // Construit la liste fusionnée : actifs (beacons récents) + connus (cache + // disque) + manuels. PeerCandidates dédoublonne et préserve l'ordre. + var candidates = PeerCandidates.Build(_discovery, cfg.ManualPeerUrls); if (candidates.Count == 0) { diff --git a/src/PSLauncher.Core/Licensing/LicenseService.cs b/src/PSLauncher.Core/Licensing/LicenseService.cs index 7aef60b..6403c3a 100644 --- a/src/PSLauncher.Core/Licensing/LicenseService.cs +++ b/src/PSLauncher.Core/Licensing/LicenseService.cs @@ -87,11 +87,19 @@ public sealed class LicenseService : ILicenseService _logger.LogInformation("Validating license at {Url} (key {KeyHint}…)", url, licenseKey.Length >= 8 ? licenseKey[..8] : licenseKey); + // Timeout court (3 s) pour fail-fast en offline. Le HttpClient global a + // Timeout=Infinite, donc sans ce CTS on hangerait jusqu'à ce que l'OS + // abandonne le SYN TCP (~15-21 s sur Windows). Le call site (Refresh + // License pendant Check Updates) catch l'exception et garde le cache + // local, donc fail-fast est sans danger. + using var validateCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + validateCts.CancelAfter(3_000); + using var httpReq = new HttpRequestMessage(HttpMethod.Post, url); httpReq.Content = JsonContent.Create(req); - using var resp = await _http.SendAsync(httpReq, ct).ConfigureAwait(false); + using var resp = await _http.SendAsync(httpReq, validateCts.Token).ConfigureAwait(false); - var bodyText = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + var bodyText = await resp.Content.ReadAsStringAsync(validateCts.Token).ConfigureAwait(false); LicenseValidationResponse? parsed; try { @@ -246,20 +254,27 @@ public sealed class LicenseService : ILicenseService var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey; try { + // Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas + // en 5s, on fallback sur l'URL publique du manifest plutôt que de bloquer + // la UI 100s. Le DL via peer LAN a déjà été tenté avant ce point — si on + // est ici c'est qu'aucun peer n'avait la version. + using var sigCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + sigCts.CancelAfter(5_000); using var req = new HttpRequestMessage(HttpMethod.Get, url); - using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); + using var resp = await _http.SendAsync(req, sigCts.Token).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) { _logger.LogInformation("Signed URL endpoint returned {Status} — falling back to public URL", (int)resp.StatusCode); return null; } - var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(sigCts.Token).ConfigureAwait(false); using var doc = JsonDocument.Parse(body); return doc.RootElement.TryGetProperty("url", out var u) ? u.GetString() : null; } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { - _logger.LogWarning(ex, "Signed URL fetch failed, falling back to public URL"); + _logger.LogWarning("Signed URL fetch failed/timeout ({Reason}), falling back to public URL", ex.Message); return null; } } diff --git a/src/PSLauncher.Core/Localization/Strings.cs b/src/PSLauncher.Core/Localization/Strings.cs index 88f5d34..ab52540 100644 --- a/src/PSLauncher.Core/Localization/Strings.cs +++ b/src/PSLauncher.Core/Localization/Strings.cs @@ -653,6 +653,11 @@ public static class Strings "وضع الخادم معطل — هذا الكمبيوتر لا يشارك ZIPs مع نظراء LAN." ); public static string MsgBoxLanCacheChange => T("Cache LAN P2P", "LAN P2P cache", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P"); + + // ---- Badge source du manifest (origine de la liste de versions affichée) ---- + public static string ManifestSourceOvh => T("🌐 OVH (en ligne)", "🌐 OVH (online)", "🌐 OVH(在线)", "🌐 OVH (ออนไลน์)", "🌐 OVH (متصل)"); + public static string ManifestSourcePeer => T("📡 Peer LAN (hors ligne)", "📡 LAN peer (offline)", "📡 局域网 Peer(离线)", "📡 LAN Peer (ออฟไลน์)", "📡 LAN Peer (غير متصل)"); + public static string ManifestSourceDiskCache => T("💾 Cache local (hors ligne)", "💾 Local cache (offline)", "💾 本地缓存(离线)", "💾 แคชในเครื่อง (ออฟไลน์)", "💾 ذاكرة محلية (غير متصل)"); public static string MsgLanCacheRestart => T( "Le démarrage / arrêt du serveur ou client LAN nécessite un redémarrage du launcher pour prendre effet.\n\nRedémarrer maintenant ?", "Starting / stopping the LAN server or client requires a launcher restart to take effect.\n\nRestart now?", diff --git a/src/PSLauncher.Core/Manifests/IManifestCache.cs b/src/PSLauncher.Core/Manifests/IManifestCache.cs new file mode 100644 index 0000000..df71a32 --- /dev/null +++ b/src/PSLauncher.Core/Manifests/IManifestCache.cs @@ -0,0 +1,26 @@ +using PSLauncher.Models; + +namespace PSLauncher.Core.Manifests; + +/// +/// Cache disque du dernier manifest récupéré avec succès. Sert deux usages : +/// 1. Permet au LanCacheServer de servir le manifest aux peers du LAN +/// (route GET /v1/manifest). +/// 2. Permet au launcher de fallback sur ce cache si OVH est injoignable et +/// qu'aucun peer LAN ne répond non plus (mode offline complet). +/// +/// Stocke le JSON brut (pas un objet désérialisé) pour préserver la signature +/// Ed25519 byte-for-byte — la canonicalisation côté validation exige les bytes +/// originaux. Path : %LocalAppData%\PSLauncher\manifest-cache.json. +/// +public interface IManifestCache +{ + /// Persiste le JSON brut sur disque (atomic via .tmp + Move). + Task SaveAsync(string rawJson, CancellationToken ct); + + /// Chemin du fichier cache (utilisé par LanCacheServer pour servir /v1/manifest). + string GetCachedFilePath(); + + /// Lit + désérialise le manifest cached. Null si pas de cache. + Task TryLoadAsync(CancellationToken ct); +} diff --git a/src/PSLauncher.Core/Manifests/IManifestService.cs b/src/PSLauncher.Core/Manifests/IManifestService.cs index e4d3971..6af56fd 100644 --- a/src/PSLauncher.Core/Manifests/IManifestService.cs +++ b/src/PSLauncher.Core/Manifests/IManifestService.cs @@ -2,8 +2,26 @@ using PSLauncher.Models; namespace PSLauncher.Core.Manifests; +/// D'où provient le dernier manifest récupéré avec succès. +public enum ManifestSource +{ + /// Pas encore récupéré dans cette session. + None, + /// Source canonique : serveur OVH via Internet. + Ovh, + /// Peer LAN (offline ou OVH down). Manifest peut être plus ancien que celui d'OVH. + Peer, + /// Cache disque local (offline + aucun peer atteignable). Manifest figé à la dernière fetch. + DiskCache, +} + public interface IManifestService { Task FetchAsync(CancellationToken ct); Task FetchReleaseNotesAsync(string url, CancellationToken ct); + + /// Source du dernier réussi. Sert au call site + /// pour décider de skip les fetch dépendants d'OVH (release notes, signed URL) + /// quand on sait qu'on est offline. + ManifestSource LastSource { get; } } diff --git a/src/PSLauncher.Core/Manifests/ManifestCache.cs b/src/PSLauncher.Core/Manifests/ManifestCache.cs new file mode 100644 index 0000000..114c6bc --- /dev/null +++ b/src/PSLauncher.Core/Manifests/ManifestCache.cs @@ -0,0 +1,61 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using PSLauncher.Core.Configuration; +using PSLauncher.Models; + +namespace PSLauncher.Core.Manifests; + +public sealed class ManifestCache : IManifestCache +{ + private const string FileName = "manifest-cache.json"; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly IConfigStore _configStore; + private readonly ILogger _logger; + + public ManifestCache(IConfigStore configStore, ILogger logger) + { + _configStore = configStore; + _logger = logger; + } + + public string GetCachedFilePath() => Path.Combine(_configStore.ConfigDirectory, FileName); + + public async Task SaveAsync(string rawJson, CancellationToken ct) + { + var path = GetCachedFilePath(); + var tmp = path + ".tmp"; + try + { + await File.WriteAllTextAsync(tmp, rawJson, Encoding.UTF8, ct).ConfigureAwait(false); + // Atomic swap : si on est interrompu pendant l'écriture, le cache reste valide. + File.Move(tmp, path, overwrite: true); + _logger.LogDebug("Manifest cached to {Path} ({Bytes} bytes)", path, rawJson.Length); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cache manifest to disk (non-fatal)"); + try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } + } + } + + public async Task TryLoadAsync(CancellationToken ct) + { + var path = GetCachedFilePath(); + if (!File.Exists(path)) return null; + try + { + var raw = await File.ReadAllTextAsync(path, Encoding.UTF8, ct).ConfigureAwait(false); + return JsonSerializer.Deserialize(raw, JsonOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read manifest cache from {Path}", path); + return null; + } + } +} diff --git a/src/PSLauncher.Core/Manifests/ManifestService.cs b/src/PSLauncher.Core/Manifests/ManifestService.cs index 1e8105b..9304bf5 100644 --- a/src/PSLauncher.Core/Manifests/ManifestService.cs +++ b/src/PSLauncher.Core/Manifests/ManifestService.cs @@ -1,6 +1,9 @@ -using System.Net.Http.Json; +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.Models; namespace PSLauncher.Core.Manifests; @@ -14,40 +17,180 @@ public sealed class ManifestService : IManifestService private readonly HttpClient _http; private readonly Func _serverBaseUrlProvider; + private readonly IManifestCache _manifestCache; + private readonly IPeerManifestFetcher _peerManifestFetcher; private readonly ILogger _logger; - public ManifestService(HttpClient http, Func serverBaseUrlProvider, ILogger logger) + public ManifestSource LastSource { get; private set; } = ManifestSource.None; + + public ManifestService( + HttpClient http, + Func serverBaseUrlProvider, + IManifestCache manifestCache, + IPeerManifestFetcher peerManifestFetcher, + ILogger logger) { _http = http; _serverBaseUrlProvider = serverBaseUrlProvider; + _manifestCache = manifestCache; + _peerManifestFetcher = peerManifestFetcher; _logger = logger; } + /// + /// 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; + 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; + 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; + 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; + return localCache; + } + + throw new InvalidOperationException("Manifest unavailable: offline, no LAN peer responded, no local cache."); + } + + private async Task FetchFromOvhAsync(CancellationToken ct) { var url = TrimSlash(_serverBaseUrlProvider()) + "/manifest"; - _logger.LogInformation("Fetching manifest: {Url}", url); + using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + ovhCts.CancelAfter(OvhFetchTimeoutMs); using var req = new HttpRequestMessage(HttpMethod.Get, url); - // Bypass des caches intermédiaires — l'utilisateur a explicitement cliqué « Vérifier ». 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, ct).ConfigureAwait(false); + using var resp = await _http.SendAsync(req, ovhCts.Token).ConfigureAwait(false); resp.EnsureSuccessStatusCode(); - var manifest = await resp.Content.ReadFromJsonAsync(JsonOptions, ct).ConfigureAwait(false) - ?? throw new InvalidOperationException("Empty manifest"); - return manifest; + 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); - using var resp = await _http.GetAsync(url, ct).ConfigureAwait(false); + // 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(ct).ConfigureAwait(false); + return await resp.Content.ReadAsStringAsync(rnCts.Token).ConfigureAwait(false); } private static string TrimSlash(string s) => s.TrimEnd('/'); diff --git a/src/PSLauncher.Models/LocalConfig.cs b/src/PSLauncher.Models/LocalConfig.cs index d767114..838c3b5 100644 --- a/src/PSLauncher.Models/LocalConfig.cs +++ b/src/PSLauncher.Models/LocalConfig.cs @@ -204,8 +204,12 @@ public sealed class LanCacheConfig /// URLs peers ajoutées manuellement (override / fallback de l'auto-discovery). /// Format : http://10.0.0.5:47623 (sans trailing slash). Probées EN PLUS /// des peers découverts auto, après ceux-ci. + /// + /// Default http://10.0.4.100:47623 = IP fixe du PC serveur ASTERION + /// dans toutes les salles VR PROSERVE. Permet la découverte instantanée au + /// tout premier démarrage du launcher (avant que le 1er beacon UDP n'arrive). /// - public List ManualPeerUrls { get; set; } = new(); + public List ManualPeerUrls { get; set; } = new() { "http://10.0.4.100:47623" }; /// /// Nombre de ZIPs à conserver en cache local (les N plus récents par mtime).