From 65ff8541b741e1078be7cde1165d74f95b55713a Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Mon, 4 May 2026 16:00:10 +0200 Subject: [PATCH] =?UTF-8?q?v0.25.4=20=E2=80=94=20Cache=20LAN=20P2P=20:=20u?= =?UTF-8?q?n=20PC=20sert=20les=20ZIPs=20aux=20autres=20du=20LAN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature : sur un site multi-PCs (salle VR), un PC peut servir les ZIPs PROSERVE qu'il a déjà téléchargés aux autres PCs du LAN au lieu de les forcer à re-DL 14 Go depuis OVH. Auto-discovery par UDP broadcast, fallback transparent OVH si aucun peer ne répond, intégrité préservée par SHA-256 du manifest signé Ed25519. Architecture (src/PSLauncher.Core/Lan/) : - ZipCacheStore : cache local des ZIPs téléchargés (sidecar SHA-256 pour servir sans re-hash). Pruning auto au top-N par mtime, protège les versions encore installées. Remplace le `File.Delete(zipPath)` post-install historique. - LanCacheServer : serveur HTTP local sur TcpListener (PAS HttpListener — il exigeait une URL ACL admin qui aurait obligé à coder un netsh dans l'installer). Routes GET /v1/has/{ver} (probe) + GET /v1/zip/{ver} (Range support manuel, ~80 LOC de parsing HTTP). Filtre RFC1918/link-local sur RemoteEndPoint avant toute IO, regex stricte sur version (anti-path-traversal), SemaphoreSlim(4) pour rate-limiter à 4 DL concurrents. - LanDiscoveryService : émet beacon UDP (255.255.255.255:47624, JSON avec host+ port+versions) toutes les 15 s en mode serveur ; écoute en mode client/serveur avec TTL 60 s sur les peers. Pure .NET, zero NuGet (pas de mDNS deps). - PeerSourceResolver : avant chaque DL, probe les peers (auto-découverts + manuels) avec timeout 1 s × max 5 peers. Vérifie size + SHA-256 contre le manifest avant de retourner une URL peer. Si null → fallback OVH transparent. Modifs flow d'install (MainViewModel) : - Avant GetSignedDownloadUrlAsync, tente PeerSourceResolver. Si peer trouvé, utilise son URL directement (le DownloadManager multi-segment marche tel quel). - RefreshUrlAsync handler : pour les peers, retourne la même URL (pas d'expiration HMAC). Pour OVH, refresh du HMAC inchangé. - Post-install : remplace File.Delete(zipPath) par ZipCacheStore.Promote + Prune. Sidebar info (visible en permanence dans le coin bas-gauche) : - Lignes "Serveur ON/KO/—", "Client ON/—", "Peers N" — refresh auto toutes les 10 s via DispatcherTimer. Diagnostic instantané sans naviguer dans Settings. Footer pendant le DL : préfixe différencié pour rendre la source évidente — "📡 LAN 10.0.0.5 • v… : … Mo/s" vs "⬇ v… : … Mo/s" (OVH). Settings → Cache LAN P2P : checkboxes ServerEnabled/ClientEnabled, ports HTTP/UDP, MaxCachedVersions, statut serveur live, liste peers découverts, champ multi-line peers manuels (override/fallback de l'auto-discovery). Toggle des flags → prompt "Restart launcher requis" car les hosted services lisent leur config au boot uniquement. Fix critique au passage : App.OnStartup appelait .Build() mais JAMAIS .StartAsync() sur l'IHost — du coup AUCUN HostedService n'a jamais tourné depuis l'introduction du pattern. Ajout de StartAsync (fire-and-forget avec log d'erreur) et StopAsync propre dans OnExit (timeout 5 s pour libérer les sockets sans TIME_WAIT bloquant). Installer (PSLauncher.iss) : règles firewall TCP 47623 + UDP 47624 préposées via netsh advfirewall, profile=private,domain (PAS public — safety net même si l'utilisateur connecte le PC à un Wi-Fi public). Inutile si firewall OFF (cas du déploiement actuel) mais pose les rails pour les déploiements futurs. Co-Authored-By: Claude Opus 4.7 (1M context) --- installer/PSLauncher.iss | 13 +- src/PSLauncher.App/App.xaml.cs | 46 ++ src/PSLauncher.App/PSLauncher.App.csproj | 6 +- .../ViewModels/MainViewModel.cs | 119 +++++- .../ViewModels/SettingsViewModel.cs | 147 +++++-- src/PSLauncher.App/Views/MainWindow.xaml | 32 ++ src/PSLauncher.App/Views/SettingsDialog.xaml | 119 ++++++ src/PSLauncher.Core/Lan/ILanCacheServer.cs | 24 ++ .../Lan/ILanDiscoveryService.cs | 30 ++ .../Lan/IPeerSourceResolver.cs | 25 ++ src/PSLauncher.Core/Lan/IZipCacheStore.cs | 48 +++ src/PSLauncher.Core/Lan/LanCacheServer.cs | 404 ++++++++++++++++++ .../Lan/LanDiscoveryService.cs | 185 ++++++++ src/PSLauncher.Core/Lan/PeerSourceResolver.cs | 127 ++++++ src/PSLauncher.Core/Lan/ZipCacheStore.cs | 159 +++++++ src/PSLauncher.Core/Localization/Strings.cs | 58 +++ src/PSLauncher.Core/PSLauncher.Core.csproj | 1 + src/PSLauncher.Models/LocalConfig.cs | 53 +++ 18 files changed, 1553 insertions(+), 43 deletions(-) create mode 100644 src/PSLauncher.Core/Lan/ILanCacheServer.cs create mode 100644 src/PSLauncher.Core/Lan/ILanDiscoveryService.cs create mode 100644 src/PSLauncher.Core/Lan/IPeerSourceResolver.cs create mode 100644 src/PSLauncher.Core/Lan/IZipCacheStore.cs create mode 100644 src/PSLauncher.Core/Lan/LanCacheServer.cs create mode 100644 src/PSLauncher.Core/Lan/LanDiscoveryService.cs create mode 100644 src/PSLauncher.Core/Lan/PeerSourceResolver.cs create mode 100644 src/PSLauncher.Core/Lan/ZipCacheStore.cs diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index d0e88ab..28d2ff4 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.24.9" +#define MyAppVersion "0.25.4" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" @@ -73,8 +73,19 @@ Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon [Run] +; Règles firewall pour le cache LAN P2P. Profile=private,domain (PAS public) = +; safety net : même si l'utilisateur connecte le PC à un Wi-Fi public, le port +; reste fermé. Le filtre RFC1918 dans LanCacheServer.cs est la première barrière, +; ces règles sont la deuxième. runhidden = pas de console visible à l'install. +Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PSLauncher LAN Cache HTTP"" dir=in action=allow program=""{app}\{#MyAppExeName}"" protocol=TCP localport=47623 profile=private,domain"; Flags: runhidden +Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PSLauncher LAN Discovery"" dir=in action=allow program=""{app}\{#MyAppExeName}"" protocol=UDP localport=47624 profile=private,domain"; Flags: runhidden + Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent +[UninstallRun] +Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PSLauncher LAN Cache HTTP"""; Flags: runhidden +Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PSLauncher LAN Discovery"""; Flags: runhidden + [UninstallDelete] ; Ne touche PAS au cache utilisateur (%LocalAppData%\PSLauncher) ni aux versions installées ; (qui peuvent vivre dans n'importe quel installRoot configuré). On supprime uniquement les diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index aebabc9..34318c3 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -18,6 +18,7 @@ using PSLauncher.Core.Localization; using PSLauncher.Core.Manifests; using PSLauncher.Core.ApiTool; using PSLauncher.Core.DocTool; +using PSLauncher.Core.Lan; using PSLauncher.Core.Health; using PSLauncher.Core.Health.OpenVRInterop; using PSLauncher.Core.Migrations; @@ -225,6 +226,32 @@ public partial class App : Application configProvider: () => sp.GetRequiredService().ApiTool, sp.GetRequiredService>())); + // Cache LAN P2P : permet aux PCs du LAN de partager les ZIPs déjà + // téléchargés au lieu de re-télécharger 14 Go depuis OVH. + // - ZipCacheStore : couche de persistence (downloads + sidecar SHA) + // - LanDiscoveryService : auto-découverte UDP des peers + // - LanCacheServer : serveur HTTP local qui sert les ZIPs cachés + // - PeerSourceResolver : avant chaque DL, probe les peers et choisit + // le premier qui matche le SHA-256 attendu + services.AddSingleton(); + services.AddSingleton(sp => new LanDiscoveryService( + configProvider: () => sp.GetRequiredService().LanCache, + cache: 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(), + logger: sp.GetRequiredService>())); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddSingleton(sp => new PeerSourceResolver( + configProvider: () => sp.GetRequiredService().LanCache, + discovery: sp.GetRequiredService(), + http: sp.GetRequiredService(), + logger: sp.GetRequiredService>())); + services.AddSingleton(); services.AddSingleton(); @@ -234,6 +261,16 @@ public partial class App : Application }) .Build(); + // Démarre les hosted services (LanCacheServer, LanDiscoveryService, …). + // Sans ce StartAsync, les BackgroundService.ExecuteAsync ne seraient + // jamais appelés — le DI les construit mais ne les "réveille" pas. + // Fire-and-forget : on ne bloque pas le UI thread, mais on garde l'erreur + // dans le log si un service plante au démarrage. + _ = _host.StartAsync().ContinueWith(t => + { + if (t.IsFaulted) Log.Error(t.Exception, "Host StartAsync failed"); + }, TaskScheduler.Default); + var window = _host.Services.GetRequiredService(); window.DataContext = _host.Services.GetRequiredService(); // Mise en page droite-à-gauche pour les langues RTL (arabe). @@ -246,6 +283,15 @@ public partial class App : Application protected override void OnExit(ExitEventArgs e) { Log.Information("PSLauncher shutting down"); + // Stop propre des hosted services (laisse 5s pour qu'ils ferment leurs + // sockets HttpListener / UdpClient sans laisser le port en TIME_WAIT trop + // longtemps). Best-effort : si timeout, on continue le shutdown quand même. + try + { + using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + _host?.StopAsync(stopCts.Token).GetAwaiter().GetResult(); + } + catch (Exception ex) { Log.Warning(ex, "Host StopAsync failed (continuing shutdown)"); } Log.CloseAndFlush(); _host?.Dispose(); base.OnExit(e); diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index b13a877..ceccd71 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.24.9 - 0.24.9.0 - 0.24.9.0 + 0.25.4 + 0.25.4.0 + 0.25.4.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index b256667..69ed342 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -19,6 +19,7 @@ using PSLauncher.Core.Localization; using PSLauncher.Core.Manifests; using PSLauncher.Core.ApiTool; using PSLauncher.Core.DocTool; +using PSLauncher.Core.Lan; using PSLauncher.Core.Health; using PSLauncher.Core.Migrations; using PSLauncher.Core.Process; @@ -51,12 +52,23 @@ public sealed partial class MainViewModel : ObservableObject private readonly IReportToolDeployer _reportDeployer; private readonly IDocToolDeployer _docDeployer; private readonly IApiToolDeployer _apiDeployer; + private readonly IPeerSourceResolver _peerResolver; + private readonly IZipCacheStore _zipCacheStore; + private readonly ILanCacheServer _lanCacheServer; + private readonly ILanDiscoveryService _lanDiscovery; private readonly ISystemHealthService _healthService; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private LicenseValidationResponse? _license; + /// + /// Host du peer LAN utilisé pour le DL en cours. Null = DL depuis OVH. + /// Sert à différencier visuellement la source dans le footer (📡 vs ⬇) + /// pendant TOUT le DL, pas juste 1 s avant que le progress prenne le relais. + /// + private string? _currentPeerHost; + private RemoteManifest? _lastManifest; private CancellationTokenSource? _activeDownloadCts; private VersionRowViewModel? _activeRow; @@ -307,6 +319,28 @@ public sealed partial class MainViewModel : ObservableObject } } + /// + /// Statut du serveur LAN affiché dans la sidebar info : + /// "ON" si activé+actif, "KO" si activé mais le bind a échoué (port pris, + /// pas d'IP LAN…), "—" si désactivé. Refresh toutes les 10 s par timer. + /// + public string LanServerStatus + { + get + { + if (!_config.LanCache.ServerEnabled) return "—"; + return _lanCacheServer.IsRunning ? "ON" : "KO"; + } + } + + /// "ON" / "—" selon le flag client. Pas de notion d'erreur côté client (probe à la demande). + public string LanClientStatus => _config.LanCache.ClientEnabled ? "ON" : "—"; + + /// Nombre de peers découverts auto via UDP (TTL 60 s côté LanDiscoveryService). + public int LanPeerCount => _lanDiscovery.ListDiscovered().Count; + + private System.Windows.Threading.DispatcherTimer? _lanInfoRefreshTimer; + public MainViewModel( IInstallationRegistry registry, IProcessLauncher processLauncher, @@ -323,6 +357,10 @@ public sealed partial class MainViewModel : ObservableObject IReportToolDeployer reportDeployer, IDocToolDeployer docDeployer, IApiToolDeployer apiDeployer, + IPeerSourceResolver peerResolver, + IZipCacheStore zipCacheStore, + ILanCacheServer lanCacheServer, + ILanDiscoveryService lanDiscovery, ISystemHealthService healthService, IServiceProvider serviceProvider, ILogger logger) @@ -342,6 +380,10 @@ public sealed partial class MainViewModel : ObservableObject _reportDeployer = reportDeployer; _docDeployer = docDeployer; _apiDeployer = apiDeployer; + _peerResolver = peerResolver; + _zipCacheStore = zipCacheStore; + _lanCacheServer = lanCacheServer; + _lanDiscovery = lanDiscovery; _healthService = healthService; _serviceProvider = serviceProvider; _logger = logger; @@ -371,6 +413,23 @@ public sealed partial class MainViewModel : ObservableObject // contient au moins un. Premier passage immédiat puis re-check tous les // RefreshIntervalSeconds. StartHealthLoop(); + + // Rafraîchit le bloc info LAN de la sidebar toutes les 10 s. Le serveur + // peut transitionner ON↔KO sans interaction user (port pris…), et le + // peer count change au rythme des beacons UDP (15 s × TTL 60 s). + // DispatcherTimer.Tick s'exécute sur le UI thread → OnPropertyChanged + // peut updater les bindings WPF directement. + _lanInfoRefreshTimer = new System.Windows.Threading.DispatcherTimer( + TimeSpan.FromSeconds(10), + System.Windows.Threading.DispatcherPriority.Background, + (_, _) => + { + OnPropertyChanged(nameof(LanServerStatus)); + OnPropertyChanged(nameof(LanClientStatus)); + OnPropertyChanged(nameof(LanPeerCount)); + }, + Application.Current.Dispatcher); + _lanInfoRefreshTimer.Start(); } /// @@ -899,20 +958,37 @@ public sealed partial class MainViewModel : ObservableObject if (!dialog.DownloadRequested) return; } - // 3) Download — tente d'abord d'obtenir une URL HMAC-signée par le serveur - // (endpoint /api/download-url/{version}). Fallback transparent sur l'URL - // publique du manifest si le serveur n'a pas le endpoint configuré. + // 3) Download — résolution d'URL en deux temps : + // A) Cache LAN : probe les peers (auto-découverts + manuels) ; si un + // d'eux a la version (taille + SHA-256 manifest match), DL depuis lui. + // Économise la bande passante OVH dans les setups multi-PCs. + // B) Sinon, fallback sur l'URL HMAC-signée serveur, puis sur l'URL + // publique du manifest si le endpoint /download-url n'est pas configuré. row.State = VersionRowState.Downloading; - var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct); - var urlString = signed ?? row.Remote.Download.Url; - var url = new Uri(urlString); + var peerSrc = await _peerResolver.TryResolveAsync( + row.Version, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256, ct); + Uri url; + if (peerSrc is not null) + { + url = peerSrc.ZipUrl; + _currentPeerHost = peerSrc.PeerHost; + StatusMessage = Strings.StatusDownloadingFromPeer(peerSrc.PeerHost, row.Version); + } + else + { + _currentPeerHost = null; + var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct); + var urlString = signed ?? row.Remote.Download.Url; + url = new Uri(urlString); + } // Callback de refresh d'URL : appelé par DownloadManager quand un segment - // reçoit 403/410 (URL HMAC expirée mid-DL). Permet aux DLs très longs sur - // connexion lente (>= 6 h, le TTL serveur) de continuer transparemment. - // Si la license a été révoquée entre-temps, le callback renvoie null et le - // DL fail proprement. + // reçoit 403/410 (URL HMAC expirée mid-DL). Pour un peer LAN l'URL n'expire + // jamais, on retourne juste la même. Pour OVH, on rappelle GetSignedDownloadUrl + // (le TTL serveur HMAC est de 6 h, suffit pour la plupart des DL mais pas pour + // les très lentes connexions ADSL). async Task RefreshUrlAsync(CancellationToken c) { + if (peerSrc is not null) return peerSrc.ZipUrl; var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, c); return fresh is null ? null : new Uri(fresh); } @@ -975,7 +1051,20 @@ public sealed partial class MainViewModel : ObservableObject }); await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct); - try { File.Delete(zipPath); } catch { /* non critique */ } + // Promote le ZIP au cache LAN (sidecar SHA écrit) puis prune les vieilles + // versions selon MaxCachedVersions. Le ZIP est déjà au bon endroit + // (DownloadManager.VerifyAndFinalizeAsync l'a Move'd vers `proserve-X.zip`), + // donc PromoteAsync est essentiellement un no-op + écriture du sidecar. + // Les versions encore installées sont protégées du pruning. + try + { + await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct); + await _zipCacheStore.PruneAsync(_config.LanCache.MaxCachedVersions, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ZIP cache promote/prune failed (non-critical)"); + } // 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config // a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire. @@ -1186,6 +1275,7 @@ public sealed partial class MainViewModel : ObservableObject _activeDownloadCts = null; _activeRow = null; _activeInstallTask = null; + _currentPeerHost = null; IsBusy = false; IsDownloadActive = false; } @@ -1291,7 +1381,12 @@ public sealed partial class MainViewModel : ObservableObject } var sb = new System.Text.StringBuilder(); - sb.Append("⬇ v").Append(row.Version).Append(" : "); + // Préfixe différencié pour rendre la source du DL visible en permanence + // dans le footer : 📡 LAN (avec IP du peer) vs ⬇ Internet (OVH). + if (_currentPeerHost is not null) + sb.Append("📡 LAN ").Append(_currentPeerHost).Append(" • v").Append(row.Version).Append(" : "); + else + sb.Append("⬇ v").Append(row.Version).Append(" : "); sb.Append(FormatSize(p.BytesDownloaded)); if (p.TotalBytes > 0) sb.Append(" / ").Append(FormatSize(p.TotalBytes)); if (p.BytesPerSecond > 0) sb.Append(" • ").Append(FormatSize((long)p.BytesPerSecond)).Append("/s"); diff --git a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs index 673e85a..e262dfa 100644 --- a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs +++ b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs @@ -10,6 +10,7 @@ using PSLauncher.App.Views; using PSLauncher.Core.Configuration; using PSLauncher.Core.ApiTool; using PSLauncher.Core.DocTool; +using PSLauncher.Core.Lan; using PSLauncher.Core.Downloads; using PSLauncher.Core.Installations; using PSLauncher.Core.Licensing; @@ -37,6 +38,9 @@ public sealed partial class SettingsViewModel : ObservableObject private readonly IReportToolDeployer _reportDeployer; private readonly IDocToolDeployer _docDeployer; private readonly IApiToolDeployer _apiDeployer; + private readonly ILanCacheServer _lanCacheServer; + private readonly ILanDiscoveryService _lanDiscovery; + private readonly IZipCacheStore _zipCacheStore; private readonly IInstallationRegistry _registry; private readonly HttpClient _http; private readonly ILogger _logger; @@ -98,6 +102,20 @@ public sealed partial class SettingsViewModel : ObservableObject = new(); public bool HasApiBackups => ApiBackups.Count > 0; + // ----- Cache LAN P2P ----- + [ObservableProperty] private bool _lanServerEnabled; + [ObservableProperty] private bool _lanClientEnabled; + [ObservableProperty] private int _lanServerPort; + [ObservableProperty] private int _lanDiscoveryPort; + [ObservableProperty] private int _lanMaxCachedVersions; + /// Multi-line text bindé sur un TextBox : 1 URL par ligne. + [ObservableProperty] private string _lanManualPeers = string.Empty; + /// Statut serveur affiché (rafraîchi à l'ouverture des Settings et toutes les 5 s). + [ObservableProperty] private string _lanServerStatus = string.Empty; + /// Liste read-only des peers découverts via UDP, formaté pour l'UI. + public System.Collections.ObjectModel.ObservableCollection LanDiscoveredPeers { get; } = new(); + public bool HasLanDiscoveredPeers => LanDiscoveredPeers.Count > 0; + // ----- Health checks (bandeau de santé système) ----- /// /// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne @@ -179,6 +197,9 @@ public sealed partial class SettingsViewModel : ObservableObject IReportToolDeployer reportDeployer, IDocToolDeployer docDeployer, IApiToolDeployer apiDeployer, + ILanCacheServer lanCacheServer, + ILanDiscoveryService lanDiscovery, + IZipCacheStore zipCacheStore, IInstallationRegistry registry, HttpClient http, ILogger logger) @@ -191,6 +212,9 @@ public sealed partial class SettingsViewModel : ObservableObject _reportDeployer = reportDeployer; _docDeployer = docDeployer; _apiDeployer = apiDeployer; + _lanCacheServer = lanCacheServer; + _lanDiscovery = lanDiscovery; + _zipCacheStore = zipCacheStore; _registry = registry; _http = http; _logger = logger; @@ -224,6 +248,14 @@ public sealed partial class SettingsViewModel : ObservableObject _apiAutoDeploy = config.ApiTool.AutoDeploy; _apiMaxBackups = config.ApiTool.MaxBackups; + _lanServerEnabled = config.LanCache.ServerEnabled; + _lanClientEnabled = config.LanCache.ClientEnabled; + _lanServerPort = config.LanCache.ServerPort; + _lanDiscoveryPort = config.LanCache.DiscoveryPort; + _lanMaxCachedVersions = config.LanCache.MaxCachedVersions; + _lanManualPeers = string.Join(Environment.NewLine, config.LanCache.ManualPeerUrls ?? new List()); + RefreshLanStatus(); + // Snapshot éditable des health checks. On clone chaque entry pour ne pas // muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save. foreach (var entry in config.HealthChecks.Checks) @@ -337,6 +369,18 @@ public sealed partial class SettingsViewModel : ObservableObject (Language ?? "auto").ToLowerInvariant(), StringComparison.Ordinal); + // Détecte les changements LAN qui nécessitent un restart du launcher. + // Les hosted services (LanCacheServer, LanDiscoveryService) lisent leur + // config UNE FOIS au démarrage de l'app — toggler ces flags après coup + // ne réveille pas le service mort. Le port aussi : changer pendant que + // le serveur tourne ne re-bind pas. ManualPeerUrls et MaxCachedVersions + // sont lus à chaque appel donc OK sans restart. + var lanRestartNeeded = + _config.LanCache.ServerEnabled != LanServerEnabled + || _config.LanCache.ClientEnabled != LanClientEnabled + || (LanServerEnabled && _config.LanCache.ServerPort != LanServerPort) + || ((LanServerEnabled || LanClientEnabled) && _config.LanCache.DiscoveryPort != LanDiscoveryPort); + _config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/'); _config.InstallRoot = InstallRoot; _config.Language = Language ?? "auto"; @@ -369,6 +413,17 @@ public sealed partial class SettingsViewModel : ObservableObject _config.ApiTool.AutoDeploy = ApiAutoDeploy; _config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups); + _config.LanCache.ServerEnabled = LanServerEnabled; + _config.LanCache.ClientEnabled = LanClientEnabled; + _config.LanCache.ServerPort = LanServerPort > 0 && LanServerPort <= 65535 ? LanServerPort : 47623; + _config.LanCache.DiscoveryPort = LanDiscoveryPort > 0 && LanDiscoveryPort <= 65535 ? LanDiscoveryPort : 47624; + _config.LanCache.MaxCachedVersions = Math.Max(0, LanMaxCachedVersions); + _config.LanCache.ManualPeerUrls = (LanManualPeers ?? string.Empty) + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Trim()) + .Where(l => l.Length > 0) + .ToList(); + // Health checks : on remplace intégralement la liste persistée par // les copies éditées. Les originaux restent en mémoire de toute façon // (les VM les ont clonés), donc Cancel a déjà préservé l'état initial. @@ -377,36 +432,42 @@ public sealed partial class SettingsViewModel : ObservableObject _configStore.Save(_config); _logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged); + // Helper local : prompt + spawn cmd qui attend la fermeture du launcher puis relance. + void PromptRestart(string message, string boxTitle, string logContext) + { + var ok = ThemedMessageBox.Show( + message, boxTitle, + MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK); + if (ok != MessageBoxResult.OK) return; + try + { + var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + if (string.IsNullOrEmpty(exe)) return; + var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\""; + Process.Start(new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = cmd, + UseShellExecute = false, + CreateNoWindow = true, + }); + Application.Current.Shutdown(); + } + catch (Exception ex) { _logger.LogError(ex, "Restart on {Context} failed", logContext); } + } + if (languageChanged) { // Le launcher doit être relancé pour que les bindings x:Static reflètent - // la nouvelle langue. Plutôt que de demander à l'utilisateur de fermer/rouvrir, - // on spawne un cmd qui attend la fermeture puis relance. - var ok = ThemedMessageBox.Show( - Strings.MsgLanguageRestart, - Strings.MsgBoxLanguageChange, - MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK); - if (ok == MessageBoxResult.OK) - { - try - { - var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; - if (!string.IsNullOrEmpty(exe)) - { - var pid = Environment.ProcessId; - var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\""; - Process.Start(new ProcessStartInfo - { - FileName = "cmd.exe", - Arguments = cmd, - UseShellExecute = false, - CreateNoWindow = true, - }); - Application.Current.Shutdown(); - } - } - catch (Exception ex) { _logger.LogError(ex, "Restart on language change failed"); } - } + // la nouvelle langue. (Si LAN a aussi changé, le même restart le couvre.) + PromptRestart(Strings.MsgLanguageRestart, Strings.MsgBoxLanguageChange, "language change"); + } + else if (lanRestartNeeded) + { + // Les hosted services LAN lisent leur config au démarrage uniquement. + // Sans restart, toggler ServerEnabled / ClientEnabled ne réveille pas + // le service mort. + PromptRestart(Strings.MsgLanCacheRestart, Strings.MsgBoxLanCacheChange, "LAN cache toggle"); } } @@ -857,6 +918,38 @@ public sealed partial class SettingsViewModel : ObservableObject } } + // ===================== CACHE LAN P2P ===================== + + /// + /// Rafraîchit les indicateurs LAN (statut serveur, liste des peers découverts). + /// Appelé à l'init + via la commande "Actualiser" dans la UI. + /// + [RelayCommand] + private void RefreshLanStatus() + { + // Statut serveur + if (_lanCacheServer.IsRunning && _lanCacheServer.BoundEndpoints.Count > 0) + { + var versions = _zipCacheStore.ListCached().Count; + LanServerStatus = Strings.SettingsLanCacheServerStatus( + string.Join(", ", _lanCacheServer.BoundEndpoints), versions); + } + else + { + LanServerStatus = Strings.SettingsLanCacheServerOff; + } + + // Liste des peers découverts (formaté pour l'affichage) + var peers = _lanDiscovery.ListDiscovered(); + LanDiscoveredPeers.Clear(); + foreach (var p in peers) + { + var versions = p.Versions.Count > 0 ? string.Join(", ", p.Versions) : "—"; + LanDiscoveredPeers.Add($"http://{p.Host}:{p.Port} • versions: [{versions}]"); + } + OnPropertyChanged(nameof(HasLanDiscoveredPeers)); + } + [RelayCommand] private async Task RedeployApiAsync() { diff --git a/src/PSLauncher.App/Views/MainWindow.xaml b/src/PSLauncher.App/Views/MainWindow.xaml index 59e2f98..459719f 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml +++ b/src/PSLauncher.App/Views/MainWindow.xaml @@ -688,6 +688,9 @@ + + + + + + + + + + diff --git a/src/PSLauncher.App/Views/SettingsDialog.xaml b/src/PSLauncher.App/Views/SettingsDialog.xaml index 6fb1ea3..38b5f0a 100644 --- a/src/PSLauncher.App/Views/SettingsDialog.xaml +++ b/src/PSLauncher.App/Views/SettingsDialog.xaml @@ -648,6 +648,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +public interface ILanCacheServer +{ + bool IsRunning { get; } + int? BoundPort { get; } + IReadOnlyList BoundEndpoints { get; } +} diff --git a/src/PSLauncher.Core/Lan/ILanDiscoveryService.cs b/src/PSLauncher.Core/Lan/ILanDiscoveryService.cs new file mode 100644 index 0000000..f58639d --- /dev/null +++ b/src/PSLauncher.Core/Lan/ILanDiscoveryService.cs @@ -0,0 +1,30 @@ +namespace PSLauncher.Core.Lan; + +/// +/// Un peer découvert via le broadcast UDP. sert au TTL : +/// si le peer ne renvoie plus de beacon pendant > 60 s, il est considéré offline +/// et n'apparaît plus dans . +/// +public sealed record DiscoveredPeer( + string Host, + int Port, + IReadOnlyList Versions, + DateTime LastSeenUtc); + +/// +/// Auto-découverte des peers PSLauncher du LAN via UDP broadcast. +/// +/// Mode serveur (ServerEnabled=true) : émet un beacon JSON sur 255.255.255.255:DiscoveryPort +/// toutes les 15 s avec son host/port HTTP + la liste des versions cachées. +/// +/// Mode client (ClientEnabled=true OU ServerEnabled=true) : écoute sur le port, +/// parse les beacons, maintient un dictionnaire interne avec TTL. +/// +/// Note : le mode serveur écoute aussi (pour ne pas avoir à demander à l'OS deux +/// fois le port via SO_REUSEADDR), mais il ignore ses propres beacons. +/// +public interface ILanDiscoveryService +{ + /// Liste des peers vus récemment (lastSeen >= now - 60s). Vide si discovery désactivée. + IReadOnlyList ListDiscovered(); +} diff --git a/src/PSLauncher.Core/Lan/IPeerSourceResolver.cs b/src/PSLauncher.Core/Lan/IPeerSourceResolver.cs new file mode 100644 index 0000000..3c8e9bd --- /dev/null +++ b/src/PSLauncher.Core/Lan/IPeerSourceResolver.cs @@ -0,0 +1,25 @@ +namespace PSLauncher.Core.Lan; + +/// +/// URL d'un peer LAN qui a déjà la version demandée et a passé la vérification +/// (taille + SHA-256 du manifest match la réponse /v1/has/). À utiliser +/// dans le DownloadJob comme URL initiale au lieu de l'URL OVH. +/// +public sealed record PeerSource(Uri ZipUrl, string PeerHost); + +/// +/// 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, +/// retourne le premier qui matche le SHA-256 attendu. Null si aucun → fallback OVH. +/// +/// Budget total : ProbeTimeoutMs × max 5 peers = ~5 s pire cas. Ne ralentit +/// jamais le path OVH au-delà. +/// +public interface IPeerSourceResolver +{ + Task TryResolveAsync( + string version, + long expectedSize, + string expectedSha256, + CancellationToken ct); +} diff --git a/src/PSLauncher.Core/Lan/IZipCacheStore.cs b/src/PSLauncher.Core/Lan/IZipCacheStore.cs new file mode 100644 index 0000000..b7099d3 --- /dev/null +++ b/src/PSLauncher.Core/Lan/IZipCacheStore.cs @@ -0,0 +1,48 @@ +namespace PSLauncher.Core.Lan; + +/// +/// Une entrée du cache local des ZIPs PROSERVE déjà téléchargés. Le SHA-256 est +/// stocké dans un fichier sidecar .sha256 à côté du ZIP — calculé une seule +/// fois au moment du Promote (déjà connu via le manifest signé), pas re-hashé à +/// chaque ListCached() (un md5 sur 14 Go prendrait 30s-3min). +/// +public sealed record CachedZip(string Version, string Path, long SizeBytes, string Sha256, DateTime ModifiedUtc); + +/// +/// Cache local des ZIPs PROSERVE. Sert deux usages : +/// 1. Permet à un PC en mode serveur LAN de servir les ZIPs aux peers du LAN. +/// 2. Permet le rollback ou la réinstall instantanée d'une version sans re-DL. +/// +/// Stockage : %LocalAppData%\PSLauncher\downloads\proserve-{version}.zip +/// (mêmechemin que les .partial du DownloadStateStore, le ZIP final y atterrit +/// déjà après VerifyAndFinalizeAsync). Le sidecar .sha256 contient le +/// hash en hex pour servir les peers sans re-calcul. +/// +/// Pruning : conserve les keepLatest plus récents par mtime, MAIS ne +/// supprime jamais un ZIP dont la version est encore installée (cf. +/// ). +/// +public interface IZipCacheStore +{ + /// Chemin canonique du ZIP pour cette version (qu'il existe ou non). + string GetCachedZipPath(string version); + + /// Vrai si on a un ZIP + sidecar SHA-256 valide pour cette version. + bool TryGetCachedEntry(string version, out CachedZip entry); + + /// + /// Promeut un ZIP final (post-VerifyAndFinalizeAsync) au statut "caché". + /// Si est déjà au bon endroit (= GetCachedZipPath), + /// no-op sur le fichier — écrit juste le sidecar SHA. Sinon, déplace. + /// + Task PromoteAsync(string srcPath, string version, string sha256, CancellationToken ct); + + /// + /// Supprime les ZIPs hors top-N par mtime. Skip silencieusement les versions + /// encore installées localement (peu importe leur âge). + /// + Task PruneAsync(int keepLatest, CancellationToken ct); + + /// Liste tous les ZIPs cachés valides (avec sidecar SHA présent). + IReadOnlyList ListCached(); +} diff --git a/src/PSLauncher.Core/Lan/LanCacheServer.cs b/src/PSLauncher.Core/Lan/LanCacheServer.cs new file mode 100644 index 0000000..d09584e --- /dev/null +++ b/src/PSLauncher.Core/Lan/LanCacheServer.cs @@ -0,0 +1,404 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using PSLauncher.Models; + +namespace PSLauncher.Core.Lan; + +/// +/// Serveur HTTP local pour le partage de ZIPs LAN, basé sur . +/// +/// Pourquoi pas HttpListener ? Parce que HttpListener.Start() exige une +/// URL ACL admin pour TOUT prefix qui n'est pas localhost — y compris quand on +/// bind sur une IP spécifique (j'avais cru le contraire et c'était faux). Avec +/// TcpListener, n'importe quel utilisateur peut bind sur 0.0.0.0:port +/// sans URL ACL, sans installer le moindre handler système. On parse HTTP à la main +/// (request line + headers + Range), c'est ~80 LOC et le protocole est trivial pour +/// nos 2 routes en GET. +/// +/// Sécurité (inchangée) : +/// - Filtre RFC1918 / link-local sur RemoteEndPoint avant tout File.IO +/// - Validation regex stricte du path version (anti-path-traversal) +/// - SemaphoreSlim(4) pour rate-limit +/// +public sealed class LanCacheServer : BackgroundService, ILanCacheServer +{ + private const int MaxConcurrentServes = 4; + private const int CopyBufferSize = 1 << 16; // 64 KiB + private const int HeaderReadTimeoutMs = 5_000; + private static readonly Regex VersionRegex = new(@"^[0-9A-Za-z._-]+$", RegexOptions.Compiled); + + private readonly Func _configProvider; + private readonly IZipCacheStore _cache; + private readonly ILogger _logger; + private readonly SemaphoreSlim _serveLimit = new(MaxConcurrentServes, MaxConcurrentServes); + + private TcpListener? _listener; + private List _boundEndpoints = new(); + private int? _boundPort; + + public LanCacheServer( + Func configProvider, + IZipCacheStore cache, + ILogger logger) + { + _configProvider = configProvider; + _cache = cache; + _logger = logger; + } + + public bool IsRunning => _listener?.Server.IsBound == true; + public int? BoundPort => _boundPort; + public IReadOnlyList BoundEndpoints => _boundEndpoints; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var cfg = _configProvider(); + if (!cfg.ServerEnabled) + { + _logger.LogInformation("LAN cache server disabled (ServerEnabled=false)"); + return; + } + + try + { + _listener = new TcpListener(IPAddress.Any, cfg.ServerPort); + _listener.Start(); + _boundPort = cfg.ServerPort; + + // Liste informative (affichée dans Settings UI). Le bind réel est + // sur 0.0.0.0 donc toutes les interfaces, mais on n'affiche que les + // IPs LAN — c'est elles que les peers vont contacter. + _boundEndpoints = GetLocalLanIPv4() + .Select(ip => $"http://{ip}:{cfg.ServerPort}") + .ToList(); + if (_boundEndpoints.Count == 0) + _boundEndpoints.Add($"http://0.0.0.0:{cfg.ServerPort}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start LAN cache server on port {Port}. Le port est-il déjà utilisé ?", cfg.ServerPort); + try { _listener?.Stop(); } catch { } + _listener = null; + return; + } + + _logger.LogInformation("LAN cache server listening on TCP :{Port} ({Endpoints})", + cfg.ServerPort, string.Join(", ", _boundEndpoints)); + + while (!stoppingToken.IsCancellationRequested) + { + TcpClient client; + try + { + client = await _listener.AcceptTcpClientAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) { break; } + catch (ObjectDisposedException) { break; } + catch (Exception ex) + { + _logger.LogDebug(ex, "AcceptTcpClient failed (will retry)"); + continue; + } + + // Une task par connexion, fire-and-forget. + _ = Task.Run(() => HandleConnectionAsync(client, stoppingToken), stoppingToken); + } + + try { _listener.Stop(); } catch { } + _listener = null; + _logger.LogInformation("LAN cache server stopped"); + } + + private async Task HandleConnectionAsync(TcpClient client, CancellationToken ct) + { + try + { + var remoteIp = (client.Client.RemoteEndPoint as IPEndPoint)?.Address; + using (client) + await using (var stream = client.GetStream()) + { + // 1) Filtre RFC1918 sur l'IP source. Defense in depth. + if (remoteIp is null || !IsRfc1918OrLinkLocal(remoteIp)) + { + _logger.LogWarning("Rejecting non-LAN request from {Remote}", remoteIp); + await WriteSimpleResponseAsync(stream, 403, "forbidden: LAN-only", ct).ConfigureAwait(false); + return; + } + + // 2) Lit la request line + headers (timeout 5s pour une connexion qui + // se connecte sans rien envoyer — anti-slow-loris basique). + using var headerCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + headerCts.CancelAfter(HeaderReadTimeoutMs); + var request = await ReadRequestAsync(stream, headerCts.Token).ConfigureAwait(false); + if (request is null) + { + await WriteSimpleResponseAsync(stream, 400, "bad request", ct).ConfigureAwait(false); + return; + } + + // 3) Routing : seul GET, sur /v1/has/{ver} ou /v1/zip/{ver} + if (!string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase)) + { + await WriteSimpleResponseAsync(stream, 405, "method not allowed", ct).ConfigureAwait(false); + return; + } + + const string hasPrefix = "/v1/has/"; + const string zipPrefix = "/v1/zip/"; + if (request.Path.StartsWith(hasPrefix, StringComparison.Ordinal)) + { + var version = request.Path.Substring(hasPrefix.Length); + await HandleHasAsync(stream, version, ct).ConfigureAwait(false); + } + else if (request.Path.StartsWith(zipPrefix, StringComparison.Ordinal)) + { + var version = request.Path.Substring(zipPrefix.Length); + await HandleZipAsync(stream, version, request.RangeHeader, ct).ConfigureAwait(false); + } + else + { + await WriteSimpleResponseAsync(stream, 404, "not found", ct).ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error handling LAN cache connection"); + } + } + + private async Task HandleHasAsync(NetworkStream stream, string version, CancellationToken ct) + { + if (!VersionRegex.IsMatch(version)) + { + await WriteSimpleResponseAsync(stream, 400, "invalid version", ct).ConfigureAwait(false); + return; + } + + if (!_cache.TryGetCachedEntry(version, out var entry)) + { + await WriteSimpleResponseAsync(stream, 404, "not in cache", ct).ConfigureAwait(false); + return; + } + + var json = JsonSerializer.Serialize(new + { + sha256 = entry.Sha256, + size = entry.SizeBytes, + mtime = entry.ModifiedUtc, + }); + var body = Encoding.UTF8.GetBytes(json); + await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false); + } + + private async Task HandleZipAsync(NetworkStream stream, string version, string? rangeHeader, CancellationToken ct) + { + if (!VersionRegex.IsMatch(version)) + { + await WriteSimpleResponseAsync(stream, 400, "invalid version", ct).ConfigureAwait(false); + return; + } + + if (!_cache.TryGetCachedEntry(version, out var entry)) + { + await WriteSimpleResponseAsync(stream, 404, "not in cache", ct).ConfigureAwait(false); + return; + } + + if (!await _serveLimit.WaitAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false)) + { + await WriteSimpleResponseAsync(stream, 503, "server busy, try later", ct).ConfigureAwait(false); + return; + } + + try + { + await using var fs = new FileStream(entry.Path, FileMode.Open, FileAccess.Read, FileShare.Read, + CopyBufferSize, useAsync: true); + + long total = fs.Length; + long start = 0; + long end = total - 1; + bool isPartial = false; + + if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase)) + { + var spec = rangeHeader.Substring("bytes=".Length); + var parts = spec.Split('-', 2); + if (parts.Length == 2 && long.TryParse(parts[0], out var s) && s >= 0 && s < total) + { + start = s; + if (parts[1].Length > 0 && long.TryParse(parts[1], out var e) && e >= start && e < total) + end = e; + isPartial = true; + } + else + { + var headers = $"Content-Range: bytes */{total}\r\n"; + await WriteSimpleResponseAsync(stream, 416, "range not satisfiable", ct, headers).ConfigureAwait(false); + return; + } + } + + var length = end - start + 1; + int code = isPartial ? 206 : 200; + string status = isPartial ? "Partial Content" : "OK"; + string? extraHeaders = null; + if (isPartial) extraHeaders = $"Content-Range: bytes {start}-{end}/{total}\r\n"; + + // Headers : Content-Type + Length + Accept-Ranges + ETag + (Content-Range si 206) + Connection: close + var sb = new StringBuilder(); + sb.Append("HTTP/1.1 ").Append(code).Append(' ').Append(status).Append("\r\n"); + sb.Append("Content-Type: application/zip\r\n"); + sb.Append("Content-Length: ").Append(length).Append("\r\n"); + sb.Append("Accept-Ranges: bytes\r\n"); + sb.Append("ETag: \"").Append(entry.Sha256).Append("\"\r\n"); + if (extraHeaders is not null) sb.Append(extraHeaders); + sb.Append("Connection: close\r\n\r\n"); + var headerBytes = Encoding.ASCII.GetBytes(sb.ToString()); + await stream.WriteAsync(headerBytes, ct).ConfigureAwait(false); + + // Body : stream le fichier en chunks + fs.Seek(start, SeekOrigin.Begin); + var buffer = new byte[CopyBufferSize]; + long remaining = length; + while (remaining > 0) + { + ct.ThrowIfCancellationRequested(); + int toRead = (int)Math.Min(buffer.Length, remaining); + int n = await fs.ReadAsync(buffer.AsMemory(0, toRead), ct).ConfigureAwait(false); + if (n <= 0) break; + await stream.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false); + remaining -= n; + } + } + finally + { + _serveLimit.Release(); + } + } + + /// + /// Parser HTTP minimal : juste la request line + le header Range (le seul qu'on + /// utilise). Les autres headers sont lus mais ignorés. Limite : 8 KiB de headers + /// max, suffisamment pour notre cas (typique 200-500 octets). + /// + private static async Task ReadRequestAsync(NetworkStream stream, CancellationToken ct) + { + var buffer = new byte[8192]; + int total = 0; + int headerEnd = -1; + while (total < buffer.Length) + { + int n = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), ct).ConfigureAwait(false); + if (n <= 0) break; + total += n; + // Cherche \r\n\r\n (fin des headers HTTP) + for (int i = 3; i < total; i++) + { + if (buffer[i - 3] == (byte)'\r' && buffer[i - 2] == (byte)'\n' + && buffer[i - 1] == (byte)'\r' && buffer[i] == (byte)'\n') + { + headerEnd = i + 1; + break; + } + } + if (headerEnd >= 0) break; + } + if (headerEnd < 0) return null; + + var raw = Encoding.ASCII.GetString(buffer, 0, headerEnd); + var lines = raw.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + if (lines.Length == 0) return null; + + // Ligne 1 : "GET /path HTTP/1.1" + var first = lines[0].Split(' ', 3); + if (first.Length < 3) return null; + var method = first[0]; + var path = first[1]; + + string? rangeHeader = null; + for (int i = 1; i < lines.Length; i++) + { + var line = lines[i]; + var colon = line.IndexOf(':'); + if (colon <= 0) continue; + var name = line.Substring(0, colon).Trim(); + var value = line.Substring(colon + 1).Trim(); + if (string.Equals(name, "Range", StringComparison.OrdinalIgnoreCase)) + rangeHeader = value; + } + + return new ParsedRequest(method, path, rangeHeader); + } + + private sealed record ParsedRequest(string Method, string Path, string? RangeHeader); + + private static Task WriteSimpleResponseAsync(NetworkStream stream, int code, string text, CancellationToken ct, string? extraHeaders = null) + { + var body = Encoding.UTF8.GetBytes(text); + return WriteResponseAsync(stream, code, ReasonPhrase(code), "text/plain; charset=utf-8", body.LongLength, extraHeaders, body, ct); + } + + private static async Task WriteResponseAsync(NetworkStream stream, int code, string status, string contentType, long contentLength, string? extraHeaders, byte[]? body, CancellationToken ct) + { + var sb = new StringBuilder(); + sb.Append("HTTP/1.1 ").Append(code).Append(' ').Append(status).Append("\r\n"); + sb.Append("Content-Type: ").Append(contentType).Append("\r\n"); + sb.Append("Content-Length: ").Append(contentLength).Append("\r\n"); + if (extraHeaders is not null) sb.Append(extraHeaders); + sb.Append("Connection: close\r\n\r\n"); + var headerBytes = Encoding.ASCII.GetBytes(sb.ToString()); + await stream.WriteAsync(headerBytes, ct).ConfigureAwait(false); + if (body is not null && body.Length > 0) + await stream.WriteAsync(body, ct).ConfigureAwait(false); + } + + private static string ReasonPhrase(int code) => code switch + { + 200 => "OK", + 206 => "Partial Content", + 400 => "Bad Request", + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 416 => "Range Not Satisfiable", + 500 => "Internal Server Error", + 503 => "Service Unavailable", + _ => "Status", + }; + + /// + /// Filtre RFC1918 + link-local (169.254.x.x) + loopback. Pas de bypass + /// possible : c'est ce qui garantit qu'aucun ZIP ne sortira accidentellement + /// du LAN même si la conf firewall est désactivée. + /// + private static bool IsRfc1918OrLinkLocal(IPAddress addr) + { + if (IPAddress.IsLoopback(addr)) return true; + if (addr.AddressFamily != AddressFamily.InterNetwork) return false; + var b = addr.GetAddressBytes(); + if (b[0] == 10) return true; + if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true; + if (b[0] == 192 && b[1] == 168) return true; + if (b[0] == 169 && b[1] == 254) return true; + return false; + } + + private static IEnumerable GetLocalLanIPv4() + { + return NetworkInterface.GetAllNetworkInterfaces() + .Where(n => n.OperationalStatus == OperationalStatus.Up + && n.NetworkInterfaceType != NetworkInterfaceType.Loopback) + .SelectMany(n => n.GetIPProperties().UnicastAddresses) + .Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork + && IsRfc1918OrLinkLocal(a.Address)) + .Select(a => a.Address); + } +} diff --git a/src/PSLauncher.Core/Lan/LanDiscoveryService.cs b/src/PSLauncher.Core/Lan/LanDiscoveryService.cs new file mode 100644 index 0000000..2ee6e3d --- /dev/null +++ b/src/PSLauncher.Core/Lan/LanDiscoveryService.cs @@ -0,0 +1,185 @@ +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; } + } +} diff --git a/src/PSLauncher.Core/Lan/PeerSourceResolver.cs b/src/PSLauncher.Core/Lan/PeerSourceResolver.cs new file mode 100644 index 0000000..a8f17e6 --- /dev/null +++ b/src/PSLauncher.Core/Lan/PeerSourceResolver.cs @@ -0,0 +1,127 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using PSLauncher.Models; + +namespace PSLauncher.Core.Lan; + +public sealed class PeerSourceResolver : IPeerSourceResolver +{ + private const int MaxPeersToProbe = 5; + + private readonly Func _configProvider; + private readonly ILanDiscoveryService _discovery; + private readonly HttpClient _http; + private readonly ILogger _logger; + + public PeerSourceResolver( + Func configProvider, + ILanDiscoveryService discovery, + HttpClient http, + ILogger logger) + { + _configProvider = configProvider; + _discovery = discovery; + _http = http; + _logger = logger; + } + + public async Task TryResolveAsync( + string version, + long expectedSize, + string expectedSha256, + CancellationToken ct) + { + var cfg = _configProvider(); + if (!cfg.ClientEnabled) + { + 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); + } + + if (candidates.Count == 0) + { + _logger.LogDebug("No peers configured/discovered, skipping LAN probe"); + 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 hasUrl = $"{baseUrl}/v1/has/{version}"; + using var resp = await _http.GetAsync(hasUrl, probeCts.Token).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode) + { + _logger.LogDebug("Peer {Url} returned {Status} for v{Version}", baseUrl, (int)resp.StatusCode, version); + continue; + } + var info = await resp.Content.ReadFromJsonAsync(probeCts.Token).ConfigureAwait(false); + if (info is null) continue; + + // Vérif size + sha256 contre le manifest signé Ed25519. + // Si un peer ment, son ZIP sera rejeté à la vérif SHA finale par + // DownloadManager — mais on peut filtrer dès maintenant pour ne + // pas perdre 14 Go de DL inutile. + if (info.Size != expectedSize) + { + _logger.LogWarning("Peer {Url} reports size {Size} for v{Version}, expected {Expected} — skipping", + baseUrl, info.Size, version, expectedSize); + continue; + } + if (!string.Equals(info.Sha256, expectedSha256, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Peer {Url} reports SHA mismatch for v{Version} — skipping", baseUrl, version); + continue; + } + + var zipUrl = new Uri($"{baseUrl}/v1/zip/{version}"); + var host = new Uri(baseUrl).Host; + _logger.LogInformation("Peer match for v{Version} : {Url}", version, baseUrl); + return new PeerSource(zipUrl, host); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + _logger.LogDebug("Peer {Url} probe timed out for v{Version}", baseUrl, version); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Peer {Url} probe failed for v{Version}", baseUrl, version); + } + } + + _logger.LogInformation("No peer has v{Version}, will fall back to OVH", version); + return null; + } + + private sealed class HasResponse + { + [JsonPropertyName("sha256")] public string Sha256 { get; set; } = ""; + [JsonPropertyName("size")] public long Size { get; set; } + [JsonPropertyName("mtime")] public DateTime Mtime { get; set; } + } +} diff --git a/src/PSLauncher.Core/Lan/ZipCacheStore.cs b/src/PSLauncher.Core/Lan/ZipCacheStore.cs new file mode 100644 index 0000000..1f4a07e --- /dev/null +++ b/src/PSLauncher.Core/Lan/ZipCacheStore.cs @@ -0,0 +1,159 @@ +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using PSLauncher.Core.Downloads; +using PSLauncher.Core.Installations; + +namespace PSLauncher.Core.Lan; + +public sealed class ZipCacheStore : IZipCacheStore +{ + // Validation stricte du nom de version pour éviter path traversal. + // Couvre semver (1.2.3) et variantes (1.2.3-rc1). + private static readonly Regex VersionRegex = new(@"^[0-9A-Za-z._-]+$", RegexOptions.Compiled); + + private const string ZipPrefix = "proserve-"; + private const string ZipSuffix = ".zip"; + private const string ShaSuffix = ".zip.sha256"; + + private readonly IDownloadStateStore _downloadStore; + private readonly IInstallationRegistry _registry; + private readonly ILogger _logger; + + public ZipCacheStore( + IDownloadStateStore downloadStore, + IInstallationRegistry registry, + ILogger logger) + { + _downloadStore = downloadStore; + _registry = registry; + _logger = logger; + } + + public string GetCachedZipPath(string version) + { + if (!VersionRegex.IsMatch(version)) + throw new ArgumentException($"Invalid version: {version}", nameof(version)); + return Path.Combine(_downloadStore.GetDownloadsDirectory(), $"{ZipPrefix}{version}{ZipSuffix}"); + } + + private string GetShaPath(string version) => + Path.Combine(_downloadStore.GetDownloadsDirectory(), $"{ZipPrefix}{version}{ShaSuffix}"); + + public bool TryGetCachedEntry(string version, out CachedZip entry) + { + entry = null!; + if (!VersionRegex.IsMatch(version)) return false; + var zipPath = GetCachedZipPath(version); + var shaPath = GetShaPath(version); + if (!File.Exists(zipPath) || !File.Exists(shaPath)) return false; + try + { + var sha = File.ReadAllText(shaPath, Encoding.ASCII).Trim(); + if (sha.Length != 64) return false; // SHA-256 hex = 64 chars + var fi = new FileInfo(zipPath); + entry = new CachedZip(version, zipPath, fi.Length, sha, fi.LastWriteTimeUtc); + return true; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Cache lookup failed for {Version}", version); + return false; + } + } + + public async Task PromoteAsync(string srcPath, string version, string sha256, CancellationToken ct) + { + if (!VersionRegex.IsMatch(version)) + throw new ArgumentException($"Invalid version: {version}", nameof(version)); + if (sha256.Length != 64) + throw new ArgumentException("SHA-256 hex must be 64 chars", nameof(sha256)); + + var dstPath = GetCachedZipPath(version); + var shaPath = GetShaPath(version); + + await Task.Run(() => + { + ct.ThrowIfCancellationRequested(); + // Si le ZIP est déjà au bon endroit (cas standard post-DownloadManager : + // VerifyAndFinalizeAsync a déjà fait File.Move(partial, final)), pas besoin + // de bouger. On écrit juste le sidecar SHA. + if (!string.Equals(Path.GetFullPath(srcPath), Path.GetFullPath(dstPath), StringComparison.OrdinalIgnoreCase)) + { + if (File.Exists(dstPath)) File.Delete(dstPath); + File.Move(srcPath, dstPath); + } + File.WriteAllText(shaPath, sha256.ToLowerInvariant(), Encoding.ASCII); + }, ct).ConfigureAwait(false); + + _logger.LogInformation("Promoted v{Version} to LAN cache (size={Size} bytes)", version, new FileInfo(dstPath).Length); + } + + public async Task PruneAsync(int keepLatest, CancellationToken ct) + { + if (keepLatest < 0) keepLatest = 0; + + await Task.Run(() => + { + try + { + // Versions encore installées : à protéger inconditionnellement. + var installedVersions = new HashSet( + _registry.Scan().Select(i => i.Version), + StringComparer.OrdinalIgnoreCase); + + var all = ListCached() + .OrderByDescending(c => c.ModifiedUtc) + .ToList(); + + int kept = 0; + foreach (var entry in all) + { + ct.ThrowIfCancellationRequested(); + bool isInstalled = installedVersions.Contains(entry.Version); + if (kept < keepLatest || isInstalled) + { + kept++; + continue; + } + + try + { + File.Delete(entry.Path); + var shaPath = GetShaPath(entry.Version); + if (File.Exists(shaPath)) File.Delete(shaPath); + _logger.LogInformation("Pruned cached ZIP v{Version} ({Size} bytes)", entry.Version, entry.SizeBytes); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not prune cached ZIP {Path} (will retry next time)", entry.Path); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Cache pruning failed (non-fatal)"); + } + }, ct).ConfigureAwait(false); + } + + public IReadOnlyList ListCached() + { + var dir = _downloadStore.GetDownloadsDirectory(); + if (!Directory.Exists(dir)) return Array.Empty(); + + var result = new List(); + foreach (var zipPath in Directory.EnumerateFiles(dir, $"{ZipPrefix}*{ZipSuffix}", SearchOption.TopDirectoryOnly)) + { + // Skip les .partial (ne matchent pas le pattern de toute façon) + var name = Path.GetFileName(zipPath); + if (!name.StartsWith(ZipPrefix, StringComparison.Ordinal)) continue; + if (!name.EndsWith(ZipSuffix, StringComparison.Ordinal)) continue; + var version = name.Substring(ZipPrefix.Length, name.Length - ZipPrefix.Length - ZipSuffix.Length); + if (!VersionRegex.IsMatch(version)) continue; + if (TryGetCachedEntry(version, out var entry)) + result.Add(entry); + } + return result; + } +} diff --git a/src/PSLauncher.Core/Localization/Strings.cs b/src/PSLauncher.Core/Localization/Strings.cs index d77b576..88f5d34 100644 --- a/src/PSLauncher.Core/Localization/Strings.cs +++ b/src/PSLauncher.Core/Localization/Strings.cs @@ -603,6 +603,64 @@ public static class Strings public static string SettingsApiBackups => SettingsReportBackups; public static string SettingsApiMaxBackups => SettingsReportMaxBackups; + // ---- Cache LAN P2P ---- + public static string StatusDownloadingFromPeer(string host, string version) => T( + $"📡 Téléchargement de v{version} depuis {host} (LAN)…", + $"📡 Downloading v{version} from {host} (LAN)…", + $"📡 正在从 {host} 下载 v{version} (局域网)…", + $"📡 กำลังดาวน์โหลด v{version} จาก {host} (LAN)…", + $"📡 جارٍ تنزيل v{version} من {host} (LAN)…" + ); + public static string SettingsLanCache => T("CACHE LAN P2P", "LAN P2P CACHE", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P"); + public static string SettingsLanCacheServerEnabled => T( + "Activer le mode serveur (partager les ZIPs aux PCs du LAN)", + "Enable server mode (share ZIPs to LAN PCs)", + "启用服务器模式(与局域网 PC 共享 ZIP)", + "เปิดใช้โหมดเซิร์ฟเวอร์ (แชร์ ZIP ไปยัง PC ใน LAN)", + "تمكين وضع الخادم (مشاركة ZIPs مع أجهزة الكمبيوتر في LAN)" + ); + public static string SettingsLanCacheClientEnabled => T( + "Activer le mode client (chercher les ZIPs sur le LAN avant OVH)", + "Enable client mode (look for ZIPs on the LAN before OVH)", + "启用客户端模式(在 OVH 之前在局域网上查找 ZIP)", + "เปิดใช้โหมดไคลเอนต์ (ค้นหา ZIP บน LAN ก่อน OVH)", + "تمكين وضع العميل (البحث عن ZIPs على LAN قبل OVH)" + ); + public static string SettingsLanCacheServerPort => T("Port HTTP serveur", "Server HTTP port", "服务器 HTTP 端口", "พอร์ต HTTP เซิร์ฟเวอร์", "منفذ HTTP الخادم"); + public static string SettingsLanCacheDiscoveryPort => T("Port UDP découverte", "Discovery UDP port", "发现 UDP 端口", "พอร์ต UDP การค้นพบ", "منفذ UDP الاكتشاف"); + public static string SettingsLanCacheMaxCached => T("ZIPs en cache (N)", "Cached ZIPs (N)", "缓存 ZIP (N)", "ZIP ในแคช (N)", "ZIPs المخزنة (N)"); + public static string SettingsLanCacheDiscoveredPeers => T("Peers découverts automatiquement", "Auto-discovered peers", "自动发现的 Peers", "Peers ที่ค้นพบอัตโนมัติ", "النظراء المكتشفون تلقائياً"); + public static string SettingsLanCacheManualPeers => T("Peers manuels (1 URL par ligne, ex: http://10.0.0.5:47623)", "Manual peers (1 URL per line, e.g. http://10.0.0.5:47623)", "手动 Peers(每行 1 个 URL,例如 http://10.0.0.5:47623)", "Peers ด้วยตนเอง (1 URL ต่อบรรทัด เช่น http://10.0.0.5:47623)", "النظراء يدوياً (URL واحد لكل سطر، مثل http://10.0.0.5:47623)"); + public static string SettingsLanCacheNoDiscovered => T( + "Aucun peer détecté pour le moment (les beacons sont émis toutes les 15 s).", + "No peer detected yet (beacons are sent every 15 s).", + "目前未检测到任何 peer (信标每 15 秒发送一次).", + "ยังไม่มี peer ตรวจพบ (สัญญาณถูกส่งทุก 15 วินาที).", + "لم يتم اكتشاف أي peer بعد (يتم إرسال إشارات كل 15 ثانية)." + ); + public static string SettingsLanCacheServerStatus(string endpoints, int versions) => T( + $"✓ Serveur actif sur {endpoints} — {versions} version(s) en cache", + $"✓ Server running on {endpoints} — {versions} version(s) cached", + $"✓ 服务器正在 {endpoints} 上运行 — 缓存了 {versions} 个版本", + $"✓ เซิร์ฟเวอร์ทำงานบน {endpoints} — แคช {versions} เวอร์ชัน", + $"✓ الخادم يعمل على {endpoints} — {versions} نسخة مخزنة" + ); + public static string SettingsLanCacheServerOff => T( + "Mode serveur désactivé — ce PC ne sert pas les ZIPs aux peers du LAN.", + "Server mode disabled — this PC does not share ZIPs to LAN peers.", + "服务器模式已禁用 — 此 PC 不会与局域网 peers 共享 ZIP。", + "โหมดเซิร์ฟเวอร์ถูกปิด — PC นี้จะไม่แชร์ ZIP ไปยัง LAN peers", + "وضع الخادم معطل — هذا الكمبيوتر لا يشارك ZIPs مع نظراء LAN." + ); + public static string MsgBoxLanCacheChange => T("Cache LAN P2P", "LAN P2P cache", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P"); + 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?", + "启动/停止局域网服务器或客户端需要重新启动启动器才能生效。\n\n现在重新启动?", + "การเริ่ม/หยุดเซิร์ฟเวอร์หรือไคลเอนต์ LAN ต้องรีสตาร์ทตัวเปิดเพื่อให้มีผล\n\nรีสตาร์ทตอนนี้?", + "يتطلب بدء / إيقاف خادم أو عميل LAN إعادة تشغيل المشغل لتفعيل التغيير.\n\nإعادة التشغيل الآن؟" + ); + // ---- Backups Report tool ---- public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة"); public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية"); diff --git a/src/PSLauncher.Core/PSLauncher.Core.csproj b/src/PSLauncher.Core/PSLauncher.Core.csproj index ce5231f..c55025b 100644 --- a/src/PSLauncher.Core/PSLauncher.Core.csproj +++ b/src/PSLauncher.Core/PSLauncher.Core.csproj @@ -10,6 +10,7 @@ + diff --git a/src/PSLauncher.Models/LocalConfig.cs b/src/PSLauncher.Models/LocalConfig.cs index 75ede97..d767114 100644 --- a/src/PSLauncher.Models/LocalConfig.cs +++ b/src/PSLauncher.Models/LocalConfig.cs @@ -71,6 +71,15 @@ public sealed class LocalConfig /// public ApiToolConfig ApiTool { get; set; } = new(); + /// + /// Cache LAN P2P : permet à un PC du LAN de partager les ZIPs déjà téléchargés + /// aux autres PCs (mode serveur), et/ou de chercher les ZIPs sur le LAN avant + /// de fallback sur OVH (mode client). Les deux flags sont indépendants. + /// Économise des dizaines de Go de bande passante Internet pour les salles + /// PROSERVE multi-postes. + /// + public LanCacheConfig LanCache { get; set; } = new(); + /// /// Surveillance d'état système affichée en bandeau sous la top bar /// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…). @@ -171,6 +180,50 @@ public sealed class ApiToolConfig public int MaxBackups { get; set; } = 3; } +/// +/// Configuration du cache LAN P2P. Symétrique : et +/// sont indépendants — un PC peut être les deux, +/// l'un ou l'autre, ou aucun. Les ports default sont choisis hors range bien +/// connu (47623 HTTP, 47624 UDP discovery) pour éviter les conflits. +/// +public sealed class LanCacheConfig +{ + /// Active le serveur HTTP local qui expose les ZIPs cachés aux peers du LAN. + public bool ServerEnabled { get; set; } = false; + + /// Active le mode client : avant de DL depuis OVH, probe les peers LAN. + public bool ClientEnabled { get; set; } = true; + + /// Port TCP du serveur HTTP local. Doit être autorisé par le firewall (rules posées par l'installeur). + public int ServerPort { get; set; } = 47623; + + /// Port UDP utilisé pour le broadcast d'auto-découverte des peers. + public int DiscoveryPort { get; set; } = 47624; + + /// + /// 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. + /// + public List ManualPeerUrls { get; set; } = new(); + + /// + /// Nombre de ZIPs à conserver en cache local (les N plus récents par mtime). + /// Default 2 = la version courante + la précédente. Permet rollback instantané + /// et le PC serveur peut servir 2 versions aux clients qui n'ont pas encore migré. + /// Les ZIPs des versions actuellement installées ne sont JAMAIS pruned, peu + /// importe leur âge. + /// + public int MaxCachedVersions { get; set; } = 2; + + /// + /// Timeout par probe HTTP /v1/has/ sur un peer. Multiplié par le nombre + /// de peers tentés, c'est le délai max avant fallback OVH. Default 1000 ms × + /// max 5 peers = 5 s pire cas avant de passer à OVH (acceptable). + /// + public int ProbeTimeoutMs { get; set; } = 1000; +} + public sealed class HealthChecksConfig { /// Période de re-vérification, en secondes. 0 = désactivé.