Compare commits

...

2 Commits

Author SHA1 Message Date
3d691c3e38 v0.25.10 — LAN P2P offline-friendly : manifest fallback + perf
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) <noreply@anthropic.com>
2026-05-04 17:51:50 +02:00
65ff8541b7 v0.25.4 — Cache LAN P2P : un PC sert les ZIPs aux autres du LAN
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) <noreply@anthropic.com>
2026-05-04 16:00:10 +02:00
25 changed files with 2244 additions and 63 deletions

View File

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher"
#define MyAppVersion "0.24.9"
#define MyAppVersion "0.25.10"
#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

View File

@@ -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;
@@ -173,10 +174,13 @@ public partial class App : Application
services.AddSingleton<IZipInstaller, ZipInstaller>();
services.AddSingleton<IDownloadStateStore, DownloadStateStore>();
services.AddSingleton<IManifestCache, ManifestCache>();
services.AddSingleton<IManifestService>(sp =>
new ManifestService(
sp.GetRequiredService<HttpClient>(),
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
sp.GetRequiredService<IManifestCache>(),
sp.GetRequiredService<IPeerManifestFetcher>(),
sp.GetRequiredService<ILogger<ManifestService>>()));
services.AddSingleton<IDownloadManager, DownloadManager>();
@@ -225,6 +229,40 @@ public partial class App : Application
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
// 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<IZipCacheStore, ZipCacheStore>();
services.AddSingleton<LanDiscoveryService>(sp => new LanDiscoveryService(
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
cache: sp.GetRequiredService<IZipCacheStore>(),
configStore: sp.GetRequiredService<IConfigStore>(),
http: sp.GetRequiredService<HttpClient>(),
logger: sp.GetRequiredService<ILogger<LanDiscoveryService>>()));
services.AddSingleton<ILanDiscoveryService>(sp => sp.GetRequiredService<LanDiscoveryService>());
services.AddHostedService(sp => sp.GetRequiredService<LanDiscoveryService>());
services.AddSingleton<LanCacheServer>(sp => new LanCacheServer(
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
cache: sp.GetRequiredService<IZipCacheStore>(),
manifestCache: sp.GetRequiredService<IManifestCache>(),
logger: sp.GetRequiredService<ILogger<LanCacheServer>>()));
services.AddSingleton<ILanCacheServer>(sp => sp.GetRequiredService<LanCacheServer>());
services.AddHostedService(sp => sp.GetRequiredService<LanCacheServer>());
services.AddSingleton<IPeerSourceResolver>(sp => new PeerSourceResolver(
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
discovery: sp.GetRequiredService<ILanDiscoveryService>(),
http: sp.GetRequiredService<HttpClient>(),
logger: sp.GetRequiredService<ILogger<PeerSourceResolver>>()));
services.AddSingleton<IPeerManifestFetcher>(sp => new PeerManifestFetcher(
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
discovery: sp.GetRequiredService<ILanDiscoveryService>(),
http: sp.GetRequiredService<HttpClient>(),
logger: sp.GetRequiredService<ILogger<PeerManifestFetcher>>()));
services.AddSingleton<IOpenVrService, OpenVrService>();
services.AddSingleton<ISystemHealthService, SystemHealthService>();
@@ -234,6 +272,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<MainWindow>();
window.DataContext = _host.Services.GetRequiredService<MainViewModel>();
// Mise en page droite-à-gauche pour les langues RTL (arabe).
@@ -246,6 +294,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);

View File

@@ -18,9 +18,9 @@
<Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.24.9</Version>
<AssemblyVersion>0.24.9.0</AssemblyVersion>
<FileVersion>0.24.9.0</FileVersion>
<Version>0.25.10</Version>
<AssemblyVersion>0.25.10.0</AssemblyVersion>
<FileVersion>0.25.10.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>

View File

@@ -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<MainViewModel> _logger;
private LicenseValidationResponse? _license;
/// <summary>
/// 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.
/// </summary>
private string? _currentPeerHost;
private RemoteManifest? _lastManifest;
private CancellationTokenSource? _activeDownloadCts;
private VersionRowViewModel? _activeRow;
@@ -307,6 +319,28 @@ public sealed partial class MainViewModel : ObservableObject
}
}
/// <summary>
/// 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.
/// </summary>
public string LanServerStatus
{
get
{
if (!_config.LanCache.ServerEnabled) return "—";
return _lanCacheServer.IsRunning ? "ON" : "KO";
}
}
/// <summary>"ON" / "—" selon le flag client. Pas de notion d'erreur côté client (probe à la demande).</summary>
public string LanClientStatus => _config.LanCache.ClientEnabled ? "ON" : "—";
/// <summary>Nombre de peers découverts auto via UDP (TTL 60 s côté LanDiscoveryService).</summary>
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<MainViewModel> 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();
}
/// <summary>
@@ -597,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)
{
@@ -882,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
{
@@ -892,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 };
@@ -899,20 +978,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 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;
var url = new Uri(urlString);
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<Uri?> 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 +1071,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 +1295,7 @@ public sealed partial class MainViewModel : ObservableObject
_activeDownloadCts = null;
_activeRow = null;
_activeInstallTask = null;
_currentPeerHost = null;
IsBusy = false;
IsDownloadActive = false;
}
@@ -1291,6 +1401,11 @@ public sealed partial class MainViewModel : ObservableObject
}
var sb = new System.Text.StringBuilder();
// 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));

View File

@@ -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<SettingsViewModel> _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;
/// <summary>Multi-line text bindé sur un TextBox : 1 URL par ligne.</summary>
[ObservableProperty] private string _lanManualPeers = string.Empty;
/// <summary>Statut serveur affiché (rafraîchi à l'ouverture des Settings et toutes les 5 s).</summary>
[ObservableProperty] private string _lanServerStatus = string.Empty;
/// <summary>Liste read-only des peers découverts via UDP, formaté pour l'UI.</summary>
public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new();
public bool HasLanDiscoveredPeers => LanDiscoveredPeers.Count > 0;
// ----- Health checks (bandeau de santé système) -----
/// <summary>
/// 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<SettingsViewModel> 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<string>());
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,23 +432,17 @@ public sealed partial class SettingsViewModel : ObservableObject
_configStore.Save(_config);
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
if (languageChanged)
// Helper local : prompt + spawn cmd qui attend la fermeture du launcher puis relance.
void PromptRestart(string message, string boxTitle, string logContext)
{
// 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,
message, boxTitle,
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
if (ok == MessageBoxResult.OK)
{
if (ok != MessageBoxResult.OK) return;
try
{
var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
if (!string.IsNullOrEmpty(exe))
{
var pid = Environment.ProcessId;
if (string.IsNullOrEmpty(exe)) return;
var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\"";
Process.Start(new ProcessStartInfo
{
@@ -404,9 +453,21 @@ public sealed partial class SettingsViewModel : ObservableObject
});
Application.Current.Shutdown();
}
catch (Exception ex) { _logger.LogError(ex, "Restart on {Context} failed", logContext); }
}
catch (Exception ex) { _logger.LogError(ex, "Restart on language change failed"); }
if (languageChanged)
{
// Le launcher doit être relancé pour que les bindings x:Static reflètent
// 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 =====================
/// <summary>
/// Rafraîchit les indicateurs LAN (statut serveur, liste des peers découverts).
/// Appelé à l'init + via la commande "Actualiser" dans la UI.
/// </summary>
[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()
{

View File

@@ -688,6 +688,9 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0"
Text="PS_Launcher"
@@ -707,6 +710,35 @@
Text="{Binding LocalIpAddress}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
<!-- Statut LAN P2P, refresh auto toutes les 10 s par DispatcherTimer
dans MainViewModel. ON / OFF (—) / KO (config ON mais bind échoué). -->
<TextBlock Grid.Row="2" Grid.Column="0"
Text="Serveur"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="État du serveur LAN P2P (partage des ZIPs aux peers du LAN)" />
<TextBlock Grid.Row="2" Grid.Column="1"
Text="{Binding LanServerStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0"
Text="Client"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="Mode client : tente d'aller chercher les ZIPs sur le LAN avant OVH" />
<TextBlock Grid.Row="3" Grid.Column="1"
Text="{Binding LanClientStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0"
Text="Peers"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="Nombre de peers LAN découverts auto via UDP (TTL 60 s)" />
<TextBlock Grid.Row="4" Grid.Column="1"
Text="{Binding LanPeerCount}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
</Grid>
</Border>

View File

@@ -648,6 +648,125 @@
</StackPanel>
</Border>
<!-- Cache LAN P2P (peers du LAN partagent les ZIPs entre eux) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsLanCache}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<CheckBox IsChecked="{Binding LanServerEnabled}"
Margin="0,4,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsLanCacheServerEnabled}" />
<CheckBox IsChecked="{Binding LanClientEnabled}"
Margin="0,4,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsLanCacheClientEnabled}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsLanCacheServerPort}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding LanServerPort, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
<TextBlock Grid.Column="3"
Text="{x:Static loc:Strings.SettingsLanCacheDiscoveryPort}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="4"
Text="{Binding LanDiscoveryPort, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</Grid>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsLanCacheMaxCached}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding LanMaxCachedVersions, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</Grid>
<!-- Status serveur (read-only, alimenté par RefreshLanStatus) -->
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding LanServerStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="↻"
Command="{Binding RefreshLanStatusCommand}"
ToolTip="Actualiser le statut + la liste des peers"
Padding="12,6" />
</Grid>
<!-- Peers découverts auto (read-only) -->
<TextBlock Text="{x:Static loc:Strings.SettingsLanCacheDiscoveredPeers}"
FontWeight="Bold" FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,16,0,6" />
<TextBlock Text="{x:Static loc:Strings.SettingsLanCacheNoDiscovered}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" FontStyle="Italic"
Visibility="{Binding HasLanDiscoveredPeers, Converter={StaticResource InverseBoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding LanDiscoveredPeers}"
Visibility="{Binding HasLanDiscoveredPeers, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#1A1A20"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="4" Padding="10,6" Margin="0,0,0,4">
<TextBlock Text="{Binding}"
Foreground="{StaticResource Brush.Text.Primary}"
FontFamily="Consolas" FontSize="11" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Peers manuels (1 URL par ligne, multiline TextBox) -->
<TextBlock Text="{x:Static loc:Strings.SettingsLanCacheManualPeers}"
FontWeight="Bold" FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,16,0,6" />
<TextBox Text="{Binding LanManualPeers, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" FontSize="11"
AcceptsReturn="True"
TextWrapping="NoWrap"
VerticalScrollBarVisibility="Auto"
MinHeight="60" />
</StackPanel>
</Border>
<!-- Outil Doc (XAMPP htdocs deploy, mirror of Report) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"

View File

@@ -0,0 +1,24 @@
namespace PSLauncher.Core.Lan;
/// <summary>
/// Serveur HTTP local qui expose les ZIPs cachés aux peers du LAN.
///
/// Routes :
/// - <c>GET /v1/has/{version}</c> → JSON <c>{sha256, size, mtime}</c> ou 404
/// - <c>GET /v1/zip/{version}</c> → stream du ZIP avec support Range
///
/// Sécurité :
/// - Bind sur les IPs locales LAN uniquement (pas <c>+</c> ni <c>*</c>) → pas
/// besoin d'URL ACL admin pour démarrer.
/// - Filtre <c>RemoteEndPoint</c> : refuse 403 toute requête venant d'une IP
/// non-RFC1918 / non-link-local.
/// - Validation regex stricte sur <c>{version}</c> avant tout File.IO.
/// - SemaphoreSlim(4) pour limiter à 4 DLs simultanés et éviter de DoS le PC
/// serveur sous charge.
/// </summary>
public interface ILanCacheServer
{
bool IsRunning { get; }
int? BoundPort { get; }
IReadOnlyList<string> BoundEndpoints { get; }
}

View File

@@ -0,0 +1,37 @@
namespace PSLauncher.Core.Lan;
/// <summary>
/// Un peer découvert via le broadcast UDP. <see cref="LastSeenUtc"/> 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 <see cref="ILanDiscoveryService.ListDiscovered"/>.
/// </summary>
public sealed record DiscoveredPeer(
string Host,
int Port,
IReadOnlyList<string> Versions,
DateTime LastSeenUtc);
/// <summary>
/// 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.
/// </summary>
public interface ILanDiscoveryService
{
/// <summary>Liste des peers vus récemment (lastSeen >= now - 60s). Vide si discovery désactivée.</summary>
IReadOnlyList<DiscoveredPeer> ListDiscovered();
/// <summary>
/// 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.
/// </summary>
IReadOnlyList<DiscoveredPeer> ListKnown();
}

View File

@@ -0,0 +1,21 @@
namespace PSLauncher.Core.Lan;
/// <summary>
/// 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).
/// </summary>
public interface IPeerManifestFetcher
{
/// <summary>
/// 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.
/// </summary>
Task<string?> TryFetchRawAsync(CancellationToken ct);
}

View File

@@ -0,0 +1,62 @@
namespace PSLauncher.Core.Lan;
/// <summary>
/// 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 <c>/v1/has/</c>). À utiliser
/// dans le DownloadJob comme URL initiale au lieu de l'URL OVH.
/// </summary>
public sealed record PeerSource(Uri ZipUrl, string PeerHost);
/// <summary>
/// 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.
/// </summary>
internal static class PeerCandidates
{
public static List<string> Build(ILanDiscoveryService discovery, IEnumerable<string> manualUrls)
{
var candidates = new List<string>();
var seen = new HashSet<string>(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;
}
}
/// <summary>
/// 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 : <c>ProbeTimeoutMs</c> × max 5 peers = ~5 s pire cas. Ne ralentit
/// jamais le path OVH au-delà.
/// </summary>
public interface IPeerSourceResolver
{
Task<PeerSource?> TryResolveAsync(
string version,
long expectedSize,
string expectedSha256,
CancellationToken ct);
}

View File

@@ -0,0 +1,48 @@
namespace PSLauncher.Core.Lan;
/// <summary>
/// Une entrée du cache local des ZIPs PROSERVE déjà téléchargés. Le SHA-256 est
/// stocké dans un fichier sidecar <c>.sha256</c> à 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).
/// </summary>
public sealed record CachedZip(string Version, string Path, long SizeBytes, string Sha256, DateTime ModifiedUtc);
/// <summary>
/// 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 : <c>%LocalAppData%\PSLauncher\downloads\proserve-{version}.zip</c>
/// (mêmechemin que les .partial du DownloadStateStore, le ZIP final y atterrit
/// déjà après VerifyAndFinalizeAsync). Le sidecar <c>.sha256</c> contient le
/// hash en hex pour servir les peers sans re-calcul.
///
/// Pruning : conserve les <c>keepLatest</c> plus récents par mtime, MAIS ne
/// supprime jamais un ZIP dont la version est encore installée (cf.
/// <see cref="PSLauncher.Core.Installations.IInstallationRegistry.Scan"/>).
/// </summary>
public interface IZipCacheStore
{
/// <summary>Chemin canonique du ZIP pour cette version (qu'il existe ou non).</summary>
string GetCachedZipPath(string version);
/// <summary>Vrai si on a un ZIP + sidecar SHA-256 valide pour cette version.</summary>
bool TryGetCachedEntry(string version, out CachedZip entry);
/// <summary>
/// Promeut un ZIP final (post-VerifyAndFinalizeAsync) au statut "caché".
/// Si <paramref name="srcPath"/> est déjà au bon endroit (= GetCachedZipPath),
/// no-op sur le fichier — écrit juste le sidecar SHA. Sinon, déplace.
/// </summary>
Task PromoteAsync(string srcPath, string version, string sha256, CancellationToken ct);
/// <summary>
/// Supprime les ZIPs hors top-N par mtime. Skip silencieusement les versions
/// encore installées localement (peu importe leur âge).
/// </summary>
Task PruneAsync(int keepLatest, CancellationToken ct);
/// <summary>Liste tous les ZIPs cachés valides (avec sidecar SHA présent).</summary>
IReadOnlyList<CachedZip> ListCached();
}

View File

@@ -0,0 +1,466 @@
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.Core.Manifests;
using PSLauncher.Models;
namespace PSLauncher.Core.Lan;
/// <summary>
/// Serveur HTTP local pour le partage de ZIPs LAN, basé sur <see cref="TcpListener"/>.
///
/// Pourquoi pas <c>HttpListener</c> ? Parce que <c>HttpListener.Start()</c> exige une
/// URL ACL admin pour TOUT prefix qui n'est pas <c>localhost</c> — y compris quand on
/// bind sur une IP spécifique (j'avais cru le contraire et c'était faux). Avec
/// <c>TcpListener</c>, n'importe quel utilisateur peut bind sur <c>0.0.0.0:port</c>
/// 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
/// </summary>
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<LanCacheConfig> _configProvider;
private readonly IZipCacheStore _cache;
private readonly IManifestCache _manifestCache;
private readonly ILogger<LanCacheServer> _logger;
private readonly SemaphoreSlim _serveLimit = new(MaxConcurrentServes, MaxConcurrentServes);
private TcpListener? _listener;
private List<string> _boundEndpoints = new();
private int? _boundPort;
public LanCacheServer(
Func<LanCacheConfig> configProvider,
IZipCacheStore cache,
IManifestCache manifestCache,
ILogger<LanCacheServer> logger)
{
_configProvider = configProvider;
_cache = cache;
_manifestCache = manifestCache;
_logger = logger;
}
public bool IsRunning => _listener?.Server.IsBound == true;
public int? BoundPort => _boundPort;
public IReadOnlyList<string> 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/";
const string manifestPath = "/v1/manifest";
const string infoPath = "/v1/info";
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 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);
}
}
}
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);
}
/// <summary>
/// Endpoint léger d'auto-discovery active. Retourne la même structure que
/// le beacon UDP : <c>{v, host, port, versions}</c>. 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.
/// </summary>
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);
}
/// <summary>
/// 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).
/// </summary>
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))
{
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();
}
}
/// <summary>
/// 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).
/// </summary>
private static async Task<ParsedRequest?> 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",
};
/// <summary>
/// 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.
/// </summary>
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<IPAddress> 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);
}
}

View File

@@ -0,0 +1,367 @@
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.Core.Configuration;
using PSLauncher.Models;
namespace PSLauncher.Core.Lan;
public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryService
{
private const int BeaconIntervalMs = 15_000;
private const int PeerTtlSeconds = 60;
/// <summary>TTL des peers persistés sur disque entre deux runs du launcher.</summary>
private const int KnownPeerStaleHours = 168; // 1 semaine
private const string KnownPeersFileName = "lan-peers.json";
private readonly Func<LanCacheConfig> _configProvider;
private readonly IZipCacheStore _cache;
private readonly IConfigStore _configStore;
private readonly HttpClient _http;
private readonly ILogger<LanDiscoveryService> _logger;
private readonly ConcurrentDictionary<string, DiscoveredPeer> _peers = new(StringComparer.OrdinalIgnoreCase);
/// <summary>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().</summary>
private List<DiscoveredPeer> _knownPeers = new();
private string? _ourLanIp;
private DateTime _lastDiskFlush = DateTime.MinValue;
public LanDiscoveryService(
Func<LanCacheConfig> configProvider,
IZipCacheStore cache,
IConfigStore configStore,
HttpClient http,
ILogger<LanDiscoveryService> logger)
{
_configProvider = configProvider;
_cache = cache;
_configStore = configStore;
_http = http;
_logger = logger;
}
public IReadOnlyList<DiscoveredPeer> ListDiscovered()
{
var cutoff = DateTime.UtcNow.AddSeconds(-PeerTtlSeconds);
return _peers.Values.Where(p => p.LastSeenUtc >= cutoff).ToList();
}
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<DiscoveredPeer> ListKnown() => _knownPeers;
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();
// 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
{
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<BeaconPayload>(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<string>?)beacon.Versions ?? Array.Empty<string>(),
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)
{
_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; }
}
}
/// <summary>
/// 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.
/// </summary>
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<string>? 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<string> Versions { get; set; } = new();
[JsonPropertyName("lastSeenUtc")] public DateTime LastSeenUtc { get; set; }
}
private string GetKnownPeersPath() =>
Path.Combine(_configStore.ConfigDirectory, KnownPeersFileName);
private List<DiscoveredPeer> LoadKnownPeers()
{
var path = GetKnownPeersPath();
if (!File.Exists(path)) return new List<DiscoveredPeer>();
try
{
var raw = File.ReadAllText(path);
var stored = JsonSerializer.Deserialize<List<StoredPeer>>(raw);
if (stored is null) return new List<DiscoveredPeer>();
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<DiscoveredPeer>();
}
}
/// <summary>
/// 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.
/// </summary>
private async Task BootstrapProbeKnownPeersAsync(LanCacheConfig cfg, CancellationToken ct)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var urls = new List<string>();
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<string>())
{
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<BeaconPayload>(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<string>?)beacon.Versions ?? Array.Empty<string>(),
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<DiscoveredPeer> 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)");
}
}
}

View File

@@ -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<LanCacheConfig> _configProvider;
private readonly ILanDiscoveryService _discovery;
private readonly HttpClient _http;
private readonly ILogger<PeerManifestFetcher> _logger;
public PeerManifestFetcher(
Func<LanCacheConfig> configProvider,
ILanDiscoveryService discovery,
HttpClient http,
ILogger<PeerManifestFetcher> logger)
{
_configProvider = configProvider;
_discovery = discovery;
_http = http;
_logger = logger;
}
public async Task<string?> 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;
}
}

View File

@@ -0,0 +1,111 @@
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<LanCacheConfig> _configProvider;
private readonly ILanDiscoveryService _discovery;
private readonly HttpClient _http;
private readonly ILogger<PeerSourceResolver> _logger;
public PeerSourceResolver(
Func<LanCacheConfig> configProvider,
ILanDiscoveryService discovery,
HttpClient http,
ILogger<PeerSourceResolver> logger)
{
_configProvider = configProvider;
_discovery = discovery;
_http = http;
_logger = logger;
}
public async Task<PeerSource?> TryResolveAsync(
string version,
long expectedSize,
string expectedSha256,
CancellationToken ct)
{
var cfg = _configProvider();
if (!cfg.ClientEnabled)
{
return null;
}
// 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)
{
_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<HasResponse>(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; }
}
}

View File

@@ -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<ZipCacheStore> _logger;
public ZipCacheStore(
IDownloadStateStore downloadStore,
IInstallationRegistry registry,
ILogger<ZipCacheStore> 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<string>(
_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<CachedZip> ListCached()
{
var dir = _downloadStore.GetDownloadsDirectory();
if (!Directory.Exists(dir)) return Array.Empty<CachedZip>();
var result = new List<CachedZip>();
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;
}
}

View File

@@ -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;
}
}

View File

@@ -603,6 +603,69 @@ 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");
// ---- 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?",
"启动/停止局域网服务器或客户端需要重新启动启动器才能生效。\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 نسخة احتياطية");

View File

@@ -0,0 +1,26 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
/// <summary>
/// Cache disque du dernier manifest récupéré avec succès. Sert deux usages :
/// 1. Permet au <c>LanCacheServer</c> de servir le manifest aux peers du LAN
/// (route <c>GET /v1/manifest</c>).
/// 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 : <c>%LocalAppData%\PSLauncher\manifest-cache.json</c>.
/// </summary>
public interface IManifestCache
{
/// <summary>Persiste le JSON brut sur disque (atomic via .tmp + Move).</summary>
Task SaveAsync(string rawJson, CancellationToken ct);
/// <summary>Chemin du fichier cache (utilisé par LanCacheServer pour servir /v1/manifest).</summary>
string GetCachedFilePath();
/// <summary>Lit + désérialise le manifest cached. Null si pas de cache.</summary>
Task<RemoteManifest?> TryLoadAsync(CancellationToken ct);
}

View File

@@ -2,8 +2,26 @@ using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
/// <summary>D'où provient le dernier manifest récupéré avec succès.</summary>
public enum ManifestSource
{
/// <summary>Pas encore récupéré dans cette session.</summary>
None,
/// <summary>Source canonique : serveur OVH via Internet.</summary>
Ovh,
/// <summary>Peer LAN (offline ou OVH down). Manifest peut être plus ancien que celui d'OVH.</summary>
Peer,
/// <summary>Cache disque local (offline + aucun peer atteignable). Manifest figé à la dernière fetch.</summary>
DiskCache,
}
public interface IManifestService
{
Task<RemoteManifest> FetchAsync(CancellationToken ct);
Task<string> FetchReleaseNotesAsync(string url, CancellationToken ct);
/// <summary>Source du dernier <see cref="FetchAsync"/> 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.</summary>
ManifestSource LastSource { get; }
}

View File

@@ -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<ManifestCache> _logger;
public ManifestCache(IConfigStore configStore, ILogger<ManifestCache> 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<RemoteManifest?> 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<RemoteManifest>(raw, JsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read manifest cache from {Path}", path);
return null;
}
}
}

View File

@@ -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<string> _serverBaseUrlProvider;
private readonly IManifestCache _manifestCache;
private readonly IPeerManifestFetcher _peerManifestFetcher;
private readonly ILogger<ManifestService> _logger;
public ManifestService(HttpClient http, Func<string> serverBaseUrlProvider, ILogger<ManifestService> logger)
public ManifestSource LastSource { get; private set; } = ManifestSource.None;
public ManifestService(
HttpClient http,
Func<string> serverBaseUrlProvider,
IManifestCache manifestCache,
IPeerManifestFetcher peerManifestFetcher,
ILogger<ManifestService> logger)
{
_http = http;
_serverBaseUrlProvider = serverBaseUrlProvider;
_manifestCache = manifestCache;
_peerManifestFetcher = peerManifestFetcher;
_logger = logger;
}
/// <summary>
/// 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 &lt;200ms si
/// dispo, OVH gagne la course en online normal.
/// </summary>
private const int OvhFetchTimeoutMs = 3_000;
/// <summary>
/// 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.
/// </summary>
public async Task<RemoteManifest> 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<RemoteManifest>(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<RemoteManifest>(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<string> 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<RemoteManifest>(JsonOptions, ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Empty manifest");
return manifest;
return await resp.Content.ReadAsStringAsync(ovhCts.Token).ConfigureAwait(false);
}
/// <summary>
/// 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.
/// </summary>
private async Task<bool> 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<string> 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('/');

View File

@@ -10,6 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageReference Include="Polly" Version="8.4.2" />
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />

View File

@@ -71,6 +71,15 @@ public sealed class LocalConfig
/// </summary>
public ApiToolConfig ApiTool { get; set; } = new();
/// <summary>
/// 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.
/// </summary>
public LanCacheConfig LanCache { get; set; } = new();
/// <summary>
/// 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,54 @@ public sealed class ApiToolConfig
public int MaxBackups { get; set; } = 3;
}
/// <summary>
/// Configuration du cache LAN P2P. Symétrique : <see cref="ServerEnabled"/> et
/// <see cref="ClientEnabled"/> 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.
/// </summary>
public sealed class LanCacheConfig
{
/// <summary>Active le serveur HTTP local qui expose les ZIPs cachés aux peers du LAN.</summary>
public bool ServerEnabled { get; set; } = false;
/// <summary>Active le mode client : avant de DL depuis OVH, probe les peers LAN.</summary>
public bool ClientEnabled { get; set; } = true;
/// <summary>Port TCP du serveur HTTP local. Doit être autorisé par le firewall (rules posées par l'installeur).</summary>
public int ServerPort { get; set; } = 47623;
/// <summary>Port UDP utilisé pour le broadcast d'auto-découverte des peers.</summary>
public int DiscoveryPort { get; set; } = 47624;
/// <summary>
/// URLs peers ajoutées manuellement (override / fallback de l'auto-discovery).
/// Format : <c>http://10.0.0.5:47623</c> (sans trailing slash). Probées EN PLUS
/// des peers découverts auto, après ceux-ci.
///
/// Default <c>http://10.0.4.100:47623</c> = 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).
/// </summary>
public List<string> ManualPeerUrls { get; set; } = new() { "http://10.0.4.100:47623" };
/// <summary>
/// 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.
/// </summary>
public int MaxCachedVersions { get; set; } = 2;
/// <summary>
/// Timeout par probe HTTP <c>/v1/has/</c> 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).
/// </summary>
public int ProbeTimeoutMs { get; set; } = 1000;
}
public sealed class HealthChecksConfig
{
/// <summary>Période de re-vérification, en secondes. 0 = désactivé.</summary>