Compare commits
2 Commits
7de0c01b97
...
3d691c3e38
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d691c3e38 | |||
| 65ff8541b7 |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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 +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,7 +1401,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; }
|
||||
}
|
||||
37
src/PSLauncher.Core/Lan/ILanDiscoveryService.cs
Normal file
37
src/PSLauncher.Core/Lan/ILanDiscoveryService.cs
Normal 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();
|
||||
}
|
||||
21
src/PSLauncher.Core/Lan/IPeerManifestFetcher.cs
Normal file
21
src/PSLauncher.Core/Lan/IPeerManifestFetcher.cs
Normal 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);
|
||||
}
|
||||
62
src/PSLauncher.Core/Lan/IPeerSourceResolver.cs
Normal file
62
src/PSLauncher.Core/Lan/IPeerSourceResolver.cs
Normal 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);
|
||||
}
|
||||
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();
|
||||
}
|
||||
466
src/PSLauncher.Core/Lan/LanCacheServer.cs
Normal file
466
src/PSLauncher.Core/Lan/LanCacheServer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
367
src/PSLauncher.Core/Lan/LanDiscoveryService.cs
Normal file
367
src/PSLauncher.Core/Lan/LanDiscoveryService.cs
Normal 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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/PSLauncher.Core/Lan/PeerManifestFetcher.cs
Normal file
75
src/PSLauncher.Core/Lan/PeerManifestFetcher.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
111
src/PSLauncher.Core/Lan/PeerSourceResolver.cs
Normal file
111
src/PSLauncher.Core/Lan/PeerSourceResolver.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 نسخة احتياطية");
|
||||
|
||||
26
src/PSLauncher.Core/Manifests/IManifestCache.cs
Normal file
26
src/PSLauncher.Core/Manifests/IManifestCache.cs
Normal 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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
61
src/PSLauncher.Core/Manifests/ManifestCache.cs
Normal file
61
src/PSLauncher.Core/Manifests/ManifestCache.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <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('/');
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user