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>
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
|
||||
#define MyAppName "PROSERVE Launcher"
|
||||
#define MyAppShortName "PS_Launcher"
|
||||
#define MyAppVersion "0.24.9"
|
||||
#define MyAppVersion "0.25.4"
|
||||
#define MyAppPublisher "ASTERION VR"
|
||||
#define MyAppURL "https://asterionvr.com"
|
||||
#define MyAppExeName "PS_Launcher.exe"
|
||||
@@ -73,8 +73,19 @@ Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
; Règles firewall pour le cache LAN P2P. Profile=private,domain (PAS public) =
|
||||
; safety net : même si l'utilisateur connecte le PC à un Wi-Fi public, le port
|
||||
; reste fermé. Le filtre RFC1918 dans LanCacheServer.cs est la première barrière,
|
||||
; ces règles sont la deuxième. runhidden = pas de console visible à l'install.
|
||||
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PSLauncher LAN Cache HTTP"" dir=in action=allow program=""{app}\{#MyAppExeName}"" protocol=TCP localport=47623 profile=private,domain"; Flags: runhidden
|
||||
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PSLauncher LAN Discovery"" dir=in action=allow program=""{app}\{#MyAppExeName}"" protocol=UDP localport=47624 profile=private,domain"; Flags: runhidden
|
||||
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[UninstallRun]
|
||||
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PSLauncher LAN Cache HTTP"""; Flags: runhidden
|
||||
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PSLauncher LAN Discovery"""; Flags: runhidden
|
||||
|
||||
[UninstallDelete]
|
||||
; Ne touche PAS au cache utilisateur (%LocalAppData%\PSLauncher) ni aux versions installées
|
||||
; (qui peuvent vivre dans n'importe quel installRoot configuré). On supprime uniquement les
|
||||
|
||||
@@ -18,6 +18,7 @@ using PSLauncher.Core.Localization;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.ApiTool;
|
||||
using PSLauncher.Core.DocTool;
|
||||
using PSLauncher.Core.Lan;
|
||||
using PSLauncher.Core.Health;
|
||||
using PSLauncher.Core.Health.OpenVRInterop;
|
||||
using PSLauncher.Core.Migrations;
|
||||
@@ -225,6 +226,32 @@ public partial class App : Application
|
||||
configProvider: () => sp.GetRequiredService<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>(),
|
||||
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>(),
|
||||
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<IOpenVrService, OpenVrService>();
|
||||
services.AddSingleton<ISystemHealthService, SystemHealthService>();
|
||||
|
||||
@@ -234,6 +261,16 @@ public partial class App : Application
|
||||
})
|
||||
.Build();
|
||||
|
||||
// Démarre les hosted services (LanCacheServer, LanDiscoveryService, …).
|
||||
// Sans ce StartAsync, les BackgroundService.ExecuteAsync ne seraient
|
||||
// jamais appelés — le DI les construit mais ne les "réveille" pas.
|
||||
// Fire-and-forget : on ne bloque pas le UI thread, mais on garde l'erreur
|
||||
// dans le log si un service plante au démarrage.
|
||||
_ = _host.StartAsync().ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted) Log.Error(t.Exception, "Host StartAsync failed");
|
||||
}, TaskScheduler.Default);
|
||||
|
||||
var window = _host.Services.GetRequiredService<MainWindow>();
|
||||
window.DataContext = _host.Services.GetRequiredService<MainViewModel>();
|
||||
// Mise en page droite-à-gauche pour les langues RTL (arabe).
|
||||
@@ -246,6 +283,15 @@ public partial class App : Application
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
Log.Information("PSLauncher shutting down");
|
||||
// Stop propre des hosted services (laisse 5s pour qu'ils ferment leurs
|
||||
// sockets HttpListener / UdpClient sans laisser le port en TIME_WAIT trop
|
||||
// longtemps). Best-effort : si timeout, on continue le shutdown quand même.
|
||||
try
|
||||
{
|
||||
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
_host?.StopAsync(stopCts.Token).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex) { Log.Warning(ex, "Host StopAsync failed (continuing shutdown)"); }
|
||||
Log.CloseAndFlush();
|
||||
_host?.Dispose();
|
||||
base.OnExit(e);
|
||||
|
||||
@@ -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.4</Version>
|
||||
<AssemblyVersion>0.25.4.0</AssemblyVersion>
|
||||
<FileVersion>0.25.4.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -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>
|
||||
@@ -899,20 +958,37 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
if (!dialog.DownloadRequested) return;
|
||||
}
|
||||
|
||||
// 3) Download — tente d'abord d'obtenir une URL HMAC-signée par le serveur
|
||||
// (endpoint /api/download-url/{version}). Fallback transparent sur l'URL
|
||||
// publique du manifest si le serveur n'a pas le endpoint configuré.
|
||||
// 3) Download — résolution d'URL en deux temps :
|
||||
// A) Cache LAN : probe les peers (auto-découverts + manuels) ; si un
|
||||
// d'eux a la version (taille + SHA-256 manifest match), DL depuis lui.
|
||||
// Économise la bande passante OVH dans les setups multi-PCs.
|
||||
// B) Sinon, fallback sur l'URL HMAC-signée serveur, puis sur l'URL
|
||||
// publique du manifest si le endpoint /download-url n'est pas configuré.
|
||||
row.State = VersionRowState.Downloading;
|
||||
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
|
||||
var urlString = signed ?? row.Remote.Download.Url;
|
||||
var url = new Uri(urlString);
|
||||
var peerSrc = await _peerResolver.TryResolveAsync(
|
||||
row.Version, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256, ct);
|
||||
Uri url;
|
||||
if (peerSrc is not null)
|
||||
{
|
||||
url = peerSrc.ZipUrl;
|
||||
_currentPeerHost = peerSrc.PeerHost;
|
||||
StatusMessage = Strings.StatusDownloadingFromPeer(peerSrc.PeerHost, row.Version);
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentPeerHost = null;
|
||||
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
|
||||
var urlString = signed ?? row.Remote.Download.Url;
|
||||
url = new Uri(urlString);
|
||||
}
|
||||
// Callback de refresh d'URL : appelé par DownloadManager quand un segment
|
||||
// reçoit 403/410 (URL HMAC expirée mid-DL). Permet aux DLs très longs sur
|
||||
// connexion lente (>= 6 h, le TTL serveur) de continuer transparemment.
|
||||
// Si la license a été révoquée entre-temps, le callback renvoie null et le
|
||||
// DL fail proprement.
|
||||
// reçoit 403/410 (URL HMAC expirée mid-DL). Pour un peer LAN l'URL n'expire
|
||||
// jamais, on retourne juste la même. Pour OVH, on rappelle GetSignedDownloadUrl
|
||||
// (le TTL serveur HMAC est de 6 h, suffit pour la plupart des DL mais pas pour
|
||||
// les très lentes connexions ADSL).
|
||||
async Task<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 +1051,20 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
});
|
||||
await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct);
|
||||
|
||||
try { File.Delete(zipPath); } catch { /* non critique */ }
|
||||
// Promote le ZIP au cache LAN (sidecar SHA écrit) puis prune les vieilles
|
||||
// versions selon MaxCachedVersions. Le ZIP est déjà au bon endroit
|
||||
// (DownloadManager.VerifyAndFinalizeAsync l'a Move'd vers `proserve-X.zip`),
|
||||
// donc PromoteAsync est essentiellement un no-op + écriture du sidecar.
|
||||
// Les versions encore installées sont protégées du pruning.
|
||||
try
|
||||
{
|
||||
await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct);
|
||||
await _zipCacheStore.PruneAsync(_config.LanCache.MaxCachedVersions, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ZIP cache promote/prune failed (non-critical)");
|
||||
}
|
||||
|
||||
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
|
||||
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
|
||||
@@ -1186,6 +1275,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_activeDownloadCts = null;
|
||||
_activeRow = null;
|
||||
_activeInstallTask = null;
|
||||
_currentPeerHost = null;
|
||||
IsBusy = false;
|
||||
IsDownloadActive = false;
|
||||
}
|
||||
@@ -1291,7 +1381,12 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("⬇ v").Append(row.Version).Append(" : ");
|
||||
// Préfixe différencié pour rendre la source du DL visible en permanence
|
||||
// dans le footer : 📡 LAN (avec IP du peer) vs ⬇ Internet (OVH).
|
||||
if (_currentPeerHost is not null)
|
||||
sb.Append("📡 LAN ").Append(_currentPeerHost).Append(" • v").Append(row.Version).Append(" : ");
|
||||
else
|
||||
sb.Append("⬇ v").Append(row.Version).Append(" : ");
|
||||
sb.Append(FormatSize(p.BytesDownloaded));
|
||||
if (p.TotalBytes > 0) sb.Append(" / ").Append(FormatSize(p.TotalBytes));
|
||||
if (p.BytesPerSecond > 0) sb.Append(" • ").Append(FormatSize((long)p.BytesPerSecond)).Append("/s");
|
||||
|
||||
@@ -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,36 +432,42 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_configStore.Save(_config);
|
||||
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
|
||||
|
||||
// Helper local : prompt + spawn cmd qui attend la fermeture du launcher puis relance.
|
||||
void PromptRestart(string message, string boxTitle, string logContext)
|
||||
{
|
||||
var ok = ThemedMessageBox.Show(
|
||||
message, boxTitle,
|
||||
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
|
||||
if (ok != MessageBoxResult.OK) return;
|
||||
try
|
||||
{
|
||||
var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
|
||||
if (string.IsNullOrEmpty(exe)) return;
|
||||
var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\"";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = cmd,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
Application.Current.Shutdown();
|
||||
}
|
||||
catch (Exception ex) { _logger.LogError(ex, "Restart on {Context} failed", logContext); }
|
||||
}
|
||||
|
||||
if (languageChanged)
|
||||
{
|
||||
// Le launcher doit être relancé pour que les bindings x:Static reflètent
|
||||
// la nouvelle langue. Plutôt que de demander à l'utilisateur de fermer/rouvrir,
|
||||
// on spawne un cmd qui attend la fermeture puis relance.
|
||||
var ok = ThemedMessageBox.Show(
|
||||
Strings.MsgLanguageRestart,
|
||||
Strings.MsgBoxLanguageChange,
|
||||
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
|
||||
if (ok == MessageBoxResult.OK)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
|
||||
if (!string.IsNullOrEmpty(exe))
|
||||
{
|
||||
var pid = Environment.ProcessId;
|
||||
var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\"";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = cmd,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
Application.Current.Shutdown();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _logger.LogError(ex, "Restart on language change failed"); }
|
||||
}
|
||||
// la nouvelle langue. (Si LAN a aussi changé, le même restart le couvre.)
|
||||
PromptRestart(Strings.MsgLanguageRestart, Strings.MsgBoxLanguageChange, "language change");
|
||||
}
|
||||
else if (lanRestartNeeded)
|
||||
{
|
||||
// Les hosted services LAN lisent leur config au démarrage uniquement.
|
||||
// Sans restart, toggler ServerEnabled / ClientEnabled ne réveille pas
|
||||
// le service mort.
|
||||
PromptRestart(Strings.MsgLanCacheRestart, Strings.MsgBoxLanCacheChange, "LAN cache toggle");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -857,6 +918,38 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== CACHE LAN P2P =====================
|
||||
|
||||
/// <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()
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
24
src/PSLauncher.Core/Lan/ILanCacheServer.cs
Normal file
24
src/PSLauncher.Core/Lan/ILanCacheServer.cs
Normal 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; }
|
||||
}
|
||||
30
src/PSLauncher.Core/Lan/ILanDiscoveryService.cs
Normal file
30
src/PSLauncher.Core/Lan/ILanDiscoveryService.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
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();
|
||||
}
|
||||
25
src/PSLauncher.Core/Lan/IPeerSourceResolver.cs
Normal file
25
src/PSLauncher.Core/Lan/IPeerSourceResolver.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
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>
|
||||
/// 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);
|
||||
}
|
||||
48
src/PSLauncher.Core/Lan/IZipCacheStore.cs
Normal file
48
src/PSLauncher.Core/Lan/IZipCacheStore.cs
Normal 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();
|
||||
}
|
||||
404
src/PSLauncher.Core/Lan/LanCacheServer.cs
Normal file
404
src/PSLauncher.Core/Lan/LanCacheServer.cs
Normal file
@@ -0,0 +1,404 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Lan;
|
||||
|
||||
/// <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 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,
|
||||
ILogger<LanCacheServer> logger)
|
||||
{
|
||||
_configProvider = configProvider;
|
||||
_cache = cache;
|
||||
_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/";
|
||||
if (request.Path.StartsWith(hasPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var version = request.Path.Substring(hasPrefix.Length);
|
||||
await HandleHasAsync(stream, version, ct).ConfigureAwait(false);
|
||||
}
|
||||
else if (request.Path.StartsWith(zipPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var version = request.Path.Substring(zipPrefix.Length);
|
||||
await HandleZipAsync(stream, version, request.RangeHeader, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteSimpleResponseAsync(stream, 404, "not found", ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error handling LAN cache connection");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleHasAsync(NetworkStream stream, string version, CancellationToken ct)
|
||||
{
|
||||
if (!VersionRegex.IsMatch(version))
|
||||
{
|
||||
await WriteSimpleResponseAsync(stream, 400, "invalid version", ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_cache.TryGetCachedEntry(version, out var entry))
|
||||
{
|
||||
await WriteSimpleResponseAsync(stream, 404, "not in cache", ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
sha256 = entry.Sha256,
|
||||
size = entry.SizeBytes,
|
||||
mtime = entry.ModifiedUtc,
|
||||
});
|
||||
var body = Encoding.UTF8.GetBytes(json);
|
||||
await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleZipAsync(NetworkStream stream, string version, string? rangeHeader, CancellationToken ct)
|
||||
{
|
||||
if (!VersionRegex.IsMatch(version))
|
||||
{
|
||||
await WriteSimpleResponseAsync(stream, 400, "invalid version", ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_cache.TryGetCachedEntry(version, out var entry))
|
||||
{
|
||||
await WriteSimpleResponseAsync(stream, 404, "not in cache", ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await _serveLimit.WaitAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false))
|
||||
{
|
||||
await WriteSimpleResponseAsync(stream, 503, "server busy, try later", ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var fs = new FileStream(entry.Path, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
CopyBufferSize, useAsync: true);
|
||||
|
||||
long total = fs.Length;
|
||||
long start = 0;
|
||||
long end = total - 1;
|
||||
bool isPartial = false;
|
||||
|
||||
if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var spec = rangeHeader.Substring("bytes=".Length);
|
||||
var parts = spec.Split('-', 2);
|
||||
if (parts.Length == 2 && long.TryParse(parts[0], out var s) && s >= 0 && s < total)
|
||||
{
|
||||
start = s;
|
||||
if (parts[1].Length > 0 && long.TryParse(parts[1], out var e) && e >= start && e < total)
|
||||
end = e;
|
||||
isPartial = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var headers = $"Content-Range: bytes */{total}\r\n";
|
||||
await WriteSimpleResponseAsync(stream, 416, "range not satisfiable", ct, headers).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var length = end - start + 1;
|
||||
int code = isPartial ? 206 : 200;
|
||||
string status = isPartial ? "Partial Content" : "OK";
|
||||
string? extraHeaders = null;
|
||||
if (isPartial) extraHeaders = $"Content-Range: bytes {start}-{end}/{total}\r\n";
|
||||
|
||||
// Headers : Content-Type + Length + Accept-Ranges + ETag + (Content-Range si 206) + Connection: close
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("HTTP/1.1 ").Append(code).Append(' ').Append(status).Append("\r\n");
|
||||
sb.Append("Content-Type: application/zip\r\n");
|
||||
sb.Append("Content-Length: ").Append(length).Append("\r\n");
|
||||
sb.Append("Accept-Ranges: bytes\r\n");
|
||||
sb.Append("ETag: \"").Append(entry.Sha256).Append("\"\r\n");
|
||||
if (extraHeaders is not null) sb.Append(extraHeaders);
|
||||
sb.Append("Connection: close\r\n\r\n");
|
||||
var headerBytes = Encoding.ASCII.GetBytes(sb.ToString());
|
||||
await stream.WriteAsync(headerBytes, ct).ConfigureAwait(false);
|
||||
|
||||
// Body : stream le fichier en chunks
|
||||
fs.Seek(start, SeekOrigin.Begin);
|
||||
var buffer = new byte[CopyBufferSize];
|
||||
long remaining = length;
|
||||
while (remaining > 0)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
int toRead = (int)Math.Min(buffer.Length, remaining);
|
||||
int n = await fs.ReadAsync(buffer.AsMemory(0, toRead), ct).ConfigureAwait(false);
|
||||
if (n <= 0) break;
|
||||
await stream.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
||||
remaining -= n;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_serveLimit.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
185
src/PSLauncher.Core/Lan/LanDiscoveryService.cs
Normal file
185
src/PSLauncher.Core/Lan/LanDiscoveryService.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Lan;
|
||||
|
||||
public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryService
|
||||
{
|
||||
private const int BeaconIntervalMs = 15_000;
|
||||
private const int PeerTtlSeconds = 60;
|
||||
|
||||
private readonly Func<LanCacheConfig> _configProvider;
|
||||
private readonly IZipCacheStore _cache;
|
||||
private readonly ILogger<LanDiscoveryService> _logger;
|
||||
|
||||
private readonly ConcurrentDictionary<string, DiscoveredPeer> _peers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private string? _ourLanIp;
|
||||
|
||||
public LanDiscoveryService(
|
||||
Func<LanCacheConfig> configProvider,
|
||||
IZipCacheStore cache,
|
||||
ILogger<LanDiscoveryService> logger)
|
||||
{
|
||||
_configProvider = configProvider;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<DiscoveredPeer> ListDiscovered()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddSeconds(-PeerTtlSeconds);
|
||||
return _peers.Values.Where(p => p.LastSeenUtc >= cutoff).ToList();
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var cfg = _configProvider();
|
||||
if (!cfg.ServerEnabled && !cfg.ClientEnabled)
|
||||
{
|
||||
_logger.LogInformation("LAN discovery disabled (both ServerEnabled and ClientEnabled are false)");
|
||||
return;
|
||||
}
|
||||
|
||||
_ourLanIp = GetLocalLanIp();
|
||||
|
||||
// Listener (toujours actif si Client OU Server activé)
|
||||
UdpClient? listener = null;
|
||||
try
|
||||
{
|
||||
listener = new UdpClient(AddressFamily.InterNetwork);
|
||||
listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||
listener.Client.Bind(new IPEndPoint(IPAddress.Any, cfg.DiscoveryPort));
|
||||
listener.EnableBroadcast = true;
|
||||
_logger.LogInformation("LAN discovery listening on UDP :{Port}", cfg.DiscoveryPort);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to bind UDP discovery socket on port {Port}", cfg.DiscoveryPort);
|
||||
listener?.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
var listenTask = ListenLoopAsync(listener, stoppingToken);
|
||||
|
||||
// Broadcaster (uniquement si Server activé)
|
||||
Task? broadcastTask = null;
|
||||
if (cfg.ServerEnabled)
|
||||
{
|
||||
broadcastTask = BroadcastLoopAsync(cfg.DiscoveryPort, cfg.ServerPort, stoppingToken);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(broadcastTask is null ? new[] { listenTask } : new[] { listenTask, broadcastTask })
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { /* expected on shutdown */ }
|
||||
finally
|
||||
{
|
||||
try { listener.Close(); } catch { }
|
||||
listener.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ListenLoopAsync(UdpClient listener, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await listener.ReceiveAsync(ct).ConfigureAwait(false);
|
||||
var senderHost = result.RemoteEndPoint.Address.ToString();
|
||||
|
||||
// Ignore nos propres beacons
|
||||
if (_ourLanIp is not null && string.Equals(senderHost, _ourLanIp, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var json = Encoding.UTF8.GetString(result.Buffer);
|
||||
var beacon = JsonSerializer.Deserialize<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;
|
||||
}
|
||||
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; }
|
||||
}
|
||||
}
|
||||
127
src/PSLauncher.Core/Lan/PeerSourceResolver.cs
Normal file
127
src/PSLauncher.Core/Lan/PeerSourceResolver.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Lan;
|
||||
|
||||
public sealed class PeerSourceResolver : IPeerSourceResolver
|
||||
{
|
||||
private const int MaxPeersToProbe = 5;
|
||||
|
||||
private readonly Func<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 des base URLs à probe : auto-découverts d'abord
|
||||
// (qui annoncent déjà avoir la version), puis manuels (qu'on probe à l'aveugle).
|
||||
var candidates = new List<string>();
|
||||
foreach (var p in _discovery.ListDiscovered())
|
||||
{
|
||||
// Optim : si le peer annonce sa liste de versions et que la nôtre n'y est
|
||||
// pas, skip. Économise un round-trip HTTP. Mais on probe quand même si
|
||||
// la liste est vide (compat anciens peers / beacon partiel).
|
||||
if (p.Versions.Count > 0 && !p.Versions.Contains(version, StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
candidates.Add($"http://{p.Host}:{p.Port}");
|
||||
}
|
||||
foreach (var url in cfg.ManualPeerUrls)
|
||||
{
|
||||
var trimmed = url.Trim().TrimEnd('/');
|
||||
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||
if (!candidates.Contains(trimmed, StringComparer.OrdinalIgnoreCase))
|
||||
candidates.Add(trimmed);
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No peers configured/discovered, skipping LAN probe");
|
||||
return null;
|
||||
}
|
||||
|
||||
var probeMs = cfg.ProbeTimeoutMs > 0 ? cfg.ProbeTimeoutMs : 1000;
|
||||
foreach (var baseUrl in candidates.Take(MaxPeersToProbe))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
probeCts.CancelAfter(probeMs);
|
||||
|
||||
var hasUrl = $"{baseUrl}/v1/has/{version}";
|
||||
using var resp = await _http.GetAsync(hasUrl, probeCts.Token).ConfigureAwait(false);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Peer {Url} returned {Status} for v{Version}", baseUrl, (int)resp.StatusCode, version);
|
||||
continue;
|
||||
}
|
||||
var info = await resp.Content.ReadFromJsonAsync<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; }
|
||||
}
|
||||
}
|
||||
159
src/PSLauncher.Core/Lan/ZipCacheStore.cs
Normal file
159
src/PSLauncher.Core/Lan/ZipCacheStore.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -603,6 +603,64 @@ public static class Strings
|
||||
public static string SettingsApiBackups => SettingsReportBackups;
|
||||
public static string SettingsApiMaxBackups => SettingsReportMaxBackups;
|
||||
|
||||
// ---- Cache LAN P2P ----
|
||||
public static string StatusDownloadingFromPeer(string host, string version) => T(
|
||||
$"📡 Téléchargement de v{version} depuis {host} (LAN)…",
|
||||
$"📡 Downloading v{version} from {host} (LAN)…",
|
||||
$"📡 正在从 {host} 下载 v{version} (局域网)…",
|
||||
$"📡 กำลังดาวน์โหลด v{version} จาก {host} (LAN)…",
|
||||
$"📡 جارٍ تنزيل v{version} من {host} (LAN)…"
|
||||
);
|
||||
public static string SettingsLanCache => T("CACHE LAN P2P", "LAN P2P CACHE", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P");
|
||||
public static string SettingsLanCacheServerEnabled => T(
|
||||
"Activer le mode serveur (partager les ZIPs aux PCs du LAN)",
|
||||
"Enable server mode (share ZIPs to LAN PCs)",
|
||||
"启用服务器模式(与局域网 PC 共享 ZIP)",
|
||||
"เปิดใช้โหมดเซิร์ฟเวอร์ (แชร์ ZIP ไปยัง PC ใน LAN)",
|
||||
"تمكين وضع الخادم (مشاركة ZIPs مع أجهزة الكمبيوتر في LAN)"
|
||||
);
|
||||
public static string SettingsLanCacheClientEnabled => T(
|
||||
"Activer le mode client (chercher les ZIPs sur le LAN avant OVH)",
|
||||
"Enable client mode (look for ZIPs on the LAN before OVH)",
|
||||
"启用客户端模式(在 OVH 之前在局域网上查找 ZIP)",
|
||||
"เปิดใช้โหมดไคลเอนต์ (ค้นหา ZIP บน LAN ก่อน OVH)",
|
||||
"تمكين وضع العميل (البحث عن ZIPs على LAN قبل OVH)"
|
||||
);
|
||||
public static string SettingsLanCacheServerPort => T("Port HTTP serveur", "Server HTTP port", "服务器 HTTP 端口", "พอร์ต HTTP เซิร์ฟเวอร์", "منفذ HTTP الخادم");
|
||||
public static string SettingsLanCacheDiscoveryPort => T("Port UDP découverte", "Discovery UDP port", "发现 UDP 端口", "พอร์ต UDP การค้นพบ", "منفذ UDP الاكتشاف");
|
||||
public static string SettingsLanCacheMaxCached => T("ZIPs en cache (N)", "Cached ZIPs (N)", "缓存 ZIP (N)", "ZIP ในแคช (N)", "ZIPs المخزنة (N)");
|
||||
public static string SettingsLanCacheDiscoveredPeers => T("Peers découverts automatiquement", "Auto-discovered peers", "自动发现的 Peers", "Peers ที่ค้นพบอัตโนมัติ", "النظراء المكتشفون تلقائياً");
|
||||
public static string SettingsLanCacheManualPeers => T("Peers manuels (1 URL par ligne, ex: http://10.0.0.5:47623)", "Manual peers (1 URL per line, e.g. http://10.0.0.5:47623)", "手动 Peers(每行 1 个 URL,例如 http://10.0.0.5:47623)", "Peers ด้วยตนเอง (1 URL ต่อบรรทัด เช่น http://10.0.0.5:47623)", "النظراء يدوياً (URL واحد لكل سطر، مثل http://10.0.0.5:47623)");
|
||||
public static string SettingsLanCacheNoDiscovered => T(
|
||||
"Aucun peer détecté pour le moment (les beacons sont émis toutes les 15 s).",
|
||||
"No peer detected yet (beacons are sent every 15 s).",
|
||||
"目前未检测到任何 peer (信标每 15 秒发送一次).",
|
||||
"ยังไม่มี peer ตรวจพบ (สัญญาณถูกส่งทุก 15 วินาที).",
|
||||
"لم يتم اكتشاف أي peer بعد (يتم إرسال إشارات كل 15 ثانية)."
|
||||
);
|
||||
public static string SettingsLanCacheServerStatus(string endpoints, int versions) => T(
|
||||
$"✓ Serveur actif sur {endpoints} — {versions} version(s) en cache",
|
||||
$"✓ Server running on {endpoints} — {versions} version(s) cached",
|
||||
$"✓ 服务器正在 {endpoints} 上运行 — 缓存了 {versions} 个版本",
|
||||
$"✓ เซิร์ฟเวอร์ทำงานบน {endpoints} — แคช {versions} เวอร์ชัน",
|
||||
$"✓ الخادم يعمل على {endpoints} — {versions} نسخة مخزنة"
|
||||
);
|
||||
public static string SettingsLanCacheServerOff => T(
|
||||
"Mode serveur désactivé — ce PC ne sert pas les ZIPs aux peers du LAN.",
|
||||
"Server mode disabled — this PC does not share ZIPs to LAN peers.",
|
||||
"服务器模式已禁用 — 此 PC 不会与局域网 peers 共享 ZIP。",
|
||||
"โหมดเซิร์ฟเวอร์ถูกปิด — PC นี้จะไม่แชร์ ZIP ไปยัง LAN peers",
|
||||
"وضع الخادم معطل — هذا الكمبيوتر لا يشارك ZIPs مع نظراء LAN."
|
||||
);
|
||||
public static string MsgBoxLanCacheChange => T("Cache LAN P2P", "LAN P2P cache", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P");
|
||||
public static string MsgLanCacheRestart => T(
|
||||
"Le démarrage / arrêt du serveur ou client LAN nécessite un redémarrage du launcher pour prendre effet.\n\nRedémarrer maintenant ?",
|
||||
"Starting / stopping the LAN server or client requires a launcher restart to take effect.\n\nRestart now?",
|
||||
"启动/停止局域网服务器或客户端需要重新启动启动器才能生效。\n\n现在重新启动?",
|
||||
"การเริ่ม/หยุดเซิร์ฟเวอร์หรือไคลเอนต์ LAN ต้องรีสตาร์ทตัวเปิดเพื่อให้มีผล\n\nรีสตาร์ทตอนนี้?",
|
||||
"يتطلب بدء / إيقاف خادم أو عميل LAN إعادة تشغيل المشغل لتفعيل التغيير.\n\nإعادة التشغيل الآن؟"
|
||||
);
|
||||
|
||||
// ---- Backups Report tool ----
|
||||
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
|
||||
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,50 @@ 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.
|
||||
/// </summary>
|
||||
public List<string> ManualPeerUrls { get; set; } = new();
|
||||
|
||||
/// <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>
|
||||
|
||||
Reference in New Issue
Block a user