v0.25.4 — Cache LAN P2P : un PC sert les ZIPs aux autres du LAN
Feature : sur un site multi-PCs (salle VR), un PC peut servir les ZIPs PROSERVE
qu'il a déjà téléchargés aux autres PCs du LAN au lieu de les forcer à re-DL
14 Go depuis OVH. Auto-discovery par UDP broadcast, fallback transparent OVH
si aucun peer ne répond, intégrité préservée par SHA-256 du manifest signé Ed25519.
Architecture (src/PSLauncher.Core/Lan/) :
- ZipCacheStore : cache local des ZIPs téléchargés (sidecar SHA-256 pour servir
sans re-hash). Pruning auto au top-N par mtime, protège les versions encore
installées. Remplace le `File.Delete(zipPath)` post-install historique.
- LanCacheServer : serveur HTTP local sur TcpListener (PAS HttpListener — il
exigeait une URL ACL admin qui aurait obligé à coder un netsh dans l'installer).
Routes GET /v1/has/{ver} (probe) + GET /v1/zip/{ver} (Range support manuel,
~80 LOC de parsing HTTP). Filtre RFC1918/link-local sur RemoteEndPoint avant
toute IO, regex stricte sur version (anti-path-traversal), SemaphoreSlim(4)
pour rate-limiter à 4 DL concurrents.
- LanDiscoveryService : émet beacon UDP (255.255.255.255:47624, JSON avec host+
port+versions) toutes les 15 s en mode serveur ; écoute en mode client/serveur
avec TTL 60 s sur les peers. Pure .NET, zero NuGet (pas de mDNS deps).
- PeerSourceResolver : avant chaque DL, probe les peers (auto-découverts +
manuels) avec timeout 1 s × max 5 peers. Vérifie size + SHA-256 contre le
manifest avant de retourner une URL peer. Si null → fallback OVH transparent.
Modifs flow d'install (MainViewModel) :
- Avant GetSignedDownloadUrlAsync, tente PeerSourceResolver. Si peer trouvé,
utilise son URL directement (le DownloadManager multi-segment marche tel quel).
- RefreshUrlAsync handler : pour les peers, retourne la même URL (pas d'expiration
HMAC). Pour OVH, refresh du HMAC inchangé.
- Post-install : remplace File.Delete(zipPath) par ZipCacheStore.Promote + Prune.
Sidebar info (visible en permanence dans le coin bas-gauche) :
- Lignes "Serveur ON/KO/—", "Client ON/—", "Peers N" — refresh auto toutes les
10 s via DispatcherTimer. Diagnostic instantané sans naviguer dans Settings.
Footer pendant le DL : préfixe différencié pour rendre la source évidente —
"📡 LAN 10.0.0.5 • v… : … Mo/s" vs "⬇ v… : … Mo/s" (OVH).
Settings → Cache LAN P2P : checkboxes ServerEnabled/ClientEnabled, ports HTTP/UDP,
MaxCachedVersions, statut serveur live, liste peers découverts, champ multi-line
peers manuels (override/fallback de l'auto-discovery). Toggle des flags →
prompt "Restart launcher requis" car les hosted services lisent leur config au
boot uniquement.
Fix critique au passage : App.OnStartup appelait .Build() mais JAMAIS .StartAsync()
sur l'IHost — du coup AUCUN HostedService n'a jamais tourné depuis l'introduction
du pattern. Ajout de StartAsync (fire-and-forget avec log d'erreur) et StopAsync
propre dans OnExit (timeout 5 s pour libérer les sockets sans TIME_WAIT bloquant).
Installer (PSLauncher.iss) : règles firewall TCP 47623 + UDP 47624 préposées
via netsh advfirewall, profile=private,domain (PAS public — safety net même
si l'utilisateur connecte le PC à un Wi-Fi public). Inutile si firewall OFF
(cas du déploiement actuel) mais pose les rails pour les déploiements futurs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ using PSLauncher.Core.Localization;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.ApiTool;
|
||||
using PSLauncher.Core.DocTool;
|
||||
using PSLauncher.Core.Lan;
|
||||
using PSLauncher.Core.Health;
|
||||
using PSLauncher.Core.Health.OpenVRInterop;
|
||||
using PSLauncher.Core.Migrations;
|
||||
@@ -225,6 +226,32 @@ public partial class App : Application
|
||||
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
|
||||
sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
|
||||
|
||||
// Cache LAN P2P : permet aux PCs du LAN de partager les ZIPs déjà
|
||||
// téléchargés au lieu de re-télécharger 14 Go depuis OVH.
|
||||
// - ZipCacheStore : couche de persistence (downloads + sidecar SHA)
|
||||
// - LanDiscoveryService : auto-découverte UDP des peers
|
||||
// - LanCacheServer : serveur HTTP local qui sert les ZIPs cachés
|
||||
// - PeerSourceResolver : avant chaque DL, probe les peers et choisit
|
||||
// le premier qui matche le SHA-256 attendu
|
||||
services.AddSingleton<IZipCacheStore, ZipCacheStore>();
|
||||
services.AddSingleton<LanDiscoveryService>(sp => new LanDiscoveryService(
|
||||
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
|
||||
cache: sp.GetRequiredService<IZipCacheStore>(),
|
||||
logger: sp.GetRequiredService<ILogger<LanDiscoveryService>>()));
|
||||
services.AddSingleton<ILanDiscoveryService>(sp => sp.GetRequiredService<LanDiscoveryService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<LanDiscoveryService>());
|
||||
services.AddSingleton<LanCacheServer>(sp => new LanCacheServer(
|
||||
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
|
||||
cache: sp.GetRequiredService<IZipCacheStore>(),
|
||||
logger: sp.GetRequiredService<ILogger<LanCacheServer>>()));
|
||||
services.AddSingleton<ILanCacheServer>(sp => sp.GetRequiredService<LanCacheServer>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<LanCacheServer>());
|
||||
services.AddSingleton<IPeerSourceResolver>(sp => new PeerSourceResolver(
|
||||
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
|
||||
discovery: sp.GetRequiredService<ILanDiscoveryService>(),
|
||||
http: sp.GetRequiredService<HttpClient>(),
|
||||
logger: sp.GetRequiredService<ILogger<PeerSourceResolver>>()));
|
||||
|
||||
services.AddSingleton<IOpenVrService, OpenVrService>();
|
||||
services.AddSingleton<ISystemHealthService, SystemHealthService>();
|
||||
|
||||
@@ -234,6 +261,16 @@ public partial class App : Application
|
||||
})
|
||||
.Build();
|
||||
|
||||
// Démarre les hosted services (LanCacheServer, LanDiscoveryService, …).
|
||||
// Sans ce StartAsync, les BackgroundService.ExecuteAsync ne seraient
|
||||
// jamais appelés — le DI les construit mais ne les "réveille" pas.
|
||||
// Fire-and-forget : on ne bloque pas le UI thread, mais on garde l'erreur
|
||||
// dans le log si un service plante au démarrage.
|
||||
_ = _host.StartAsync().ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted) Log.Error(t.Exception, "Host StartAsync failed");
|
||||
}, TaskScheduler.Default);
|
||||
|
||||
var window = _host.Services.GetRequiredService<MainWindow>();
|
||||
window.DataContext = _host.Services.GetRequiredService<MainViewModel>();
|
||||
// Mise en page droite-à-gauche pour les langues RTL (arabe).
|
||||
@@ -246,6 +283,15 @@ public partial class App : Application
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
Log.Information("PSLauncher shutting down");
|
||||
// Stop propre des hosted services (laisse 5s pour qu'ils ferment leurs
|
||||
// sockets HttpListener / UdpClient sans laisser le port en TIME_WAIT trop
|
||||
// longtemps). Best-effort : si timeout, on continue le shutdown quand même.
|
||||
try
|
||||
{
|
||||
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
_host?.StopAsync(stopCts.Token).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex) { Log.Warning(ex, "Host StopAsync failed (continuing shutdown)"); }
|
||||
Log.CloseAndFlush();
|
||||
_host?.Dispose();
|
||||
base.OnExit(e);
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
<Product>PROSERVE Launcher</Product>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
<Version>0.24.9</Version>
|
||||
<AssemblyVersion>0.24.9.0</AssemblyVersion>
|
||||
<FileVersion>0.24.9.0</FileVersion>
|
||||
<Version>0.25.4</Version>
|
||||
<AssemblyVersion>0.25.4.0</AssemblyVersion>
|
||||
<FileVersion>0.25.4.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -19,6 +19,7 @@ using PSLauncher.Core.Localization;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.ApiTool;
|
||||
using PSLauncher.Core.DocTool;
|
||||
using PSLauncher.Core.Lan;
|
||||
using PSLauncher.Core.Health;
|
||||
using PSLauncher.Core.Migrations;
|
||||
using PSLauncher.Core.Process;
|
||||
@@ -51,12 +52,23 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
private readonly IReportToolDeployer _reportDeployer;
|
||||
private readonly IDocToolDeployer _docDeployer;
|
||||
private readonly IApiToolDeployer _apiDeployer;
|
||||
private readonly IPeerSourceResolver _peerResolver;
|
||||
private readonly IZipCacheStore _zipCacheStore;
|
||||
private readonly ILanCacheServer _lanCacheServer;
|
||||
private readonly ILanDiscoveryService _lanDiscovery;
|
||||
private readonly ISystemHealthService _healthService;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<MainViewModel> _logger;
|
||||
|
||||
private LicenseValidationResponse? _license;
|
||||
|
||||
/// <summary>
|
||||
/// Host du peer LAN utilisé pour le DL en cours. Null = DL depuis OVH.
|
||||
/// Sert à différencier visuellement la source dans le footer (📡 vs ⬇)
|
||||
/// pendant TOUT le DL, pas juste 1 s avant que le progress prenne le relais.
|
||||
/// </summary>
|
||||
private string? _currentPeerHost;
|
||||
|
||||
private RemoteManifest? _lastManifest;
|
||||
private CancellationTokenSource? _activeDownloadCts;
|
||||
private VersionRowViewModel? _activeRow;
|
||||
@@ -307,6 +319,28 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statut du serveur LAN affiché dans la sidebar info :
|
||||
/// "ON" si activé+actif, "KO" si activé mais le bind a échoué (port pris,
|
||||
/// pas d'IP LAN…), "—" si désactivé. Refresh toutes les 10 s par timer.
|
||||
/// </summary>
|
||||
public string LanServerStatus
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_config.LanCache.ServerEnabled) return "—";
|
||||
return _lanCacheServer.IsRunning ? "ON" : "KO";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>"ON" / "—" selon le flag client. Pas de notion d'erreur côté client (probe à la demande).</summary>
|
||||
public string LanClientStatus => _config.LanCache.ClientEnabled ? "ON" : "—";
|
||||
|
||||
/// <summary>Nombre de peers découverts auto via UDP (TTL 60 s côté LanDiscoveryService).</summary>
|
||||
public int LanPeerCount => _lanDiscovery.ListDiscovered().Count;
|
||||
|
||||
private System.Windows.Threading.DispatcherTimer? _lanInfoRefreshTimer;
|
||||
|
||||
public MainViewModel(
|
||||
IInstallationRegistry registry,
|
||||
IProcessLauncher processLauncher,
|
||||
@@ -323,6 +357,10 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
IReportToolDeployer reportDeployer,
|
||||
IDocToolDeployer docDeployer,
|
||||
IApiToolDeployer apiDeployer,
|
||||
IPeerSourceResolver peerResolver,
|
||||
IZipCacheStore zipCacheStore,
|
||||
ILanCacheServer lanCacheServer,
|
||||
ILanDiscoveryService lanDiscovery,
|
||||
ISystemHealthService healthService,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<MainViewModel> logger)
|
||||
@@ -342,6 +380,10 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_reportDeployer = reportDeployer;
|
||||
_docDeployer = docDeployer;
|
||||
_apiDeployer = apiDeployer;
|
||||
_peerResolver = peerResolver;
|
||||
_zipCacheStore = zipCacheStore;
|
||||
_lanCacheServer = lanCacheServer;
|
||||
_lanDiscovery = lanDiscovery;
|
||||
_healthService = healthService;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
@@ -371,6 +413,23 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
// contient au moins un. Premier passage immédiat puis re-check tous les
|
||||
// RefreshIntervalSeconds.
|
||||
StartHealthLoop();
|
||||
|
||||
// Rafraîchit le bloc info LAN de la sidebar toutes les 10 s. Le serveur
|
||||
// peut transitionner ON↔KO sans interaction user (port pris…), et le
|
||||
// peer count change au rythme des beacons UDP (15 s × TTL 60 s).
|
||||
// DispatcherTimer.Tick s'exécute sur le UI thread → OnPropertyChanged
|
||||
// peut updater les bindings WPF directement.
|
||||
_lanInfoRefreshTimer = new System.Windows.Threading.DispatcherTimer(
|
||||
TimeSpan.FromSeconds(10),
|
||||
System.Windows.Threading.DispatcherPriority.Background,
|
||||
(_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(LanServerStatus));
|
||||
OnPropertyChanged(nameof(LanClientStatus));
|
||||
OnPropertyChanged(nameof(LanPeerCount));
|
||||
},
|
||||
Application.Current.Dispatcher);
|
||||
_lanInfoRefreshTimer.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -899,20 +958,37 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
if (!dialog.DownloadRequested) return;
|
||||
}
|
||||
|
||||
// 3) Download — tente d'abord d'obtenir une URL HMAC-signée par le serveur
|
||||
// (endpoint /api/download-url/{version}). Fallback transparent sur l'URL
|
||||
// publique du manifest si le serveur n'a pas le endpoint configuré.
|
||||
// 3) Download — résolution d'URL en deux temps :
|
||||
// A) Cache LAN : probe les peers (auto-découverts + manuels) ; si un
|
||||
// d'eux a la version (taille + SHA-256 manifest match), DL depuis lui.
|
||||
// Économise la bande passante OVH dans les setups multi-PCs.
|
||||
// B) Sinon, fallback sur l'URL HMAC-signée serveur, puis sur l'URL
|
||||
// publique du manifest si le endpoint /download-url n'est pas configuré.
|
||||
row.State = VersionRowState.Downloading;
|
||||
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
|
||||
var urlString = signed ?? row.Remote.Download.Url;
|
||||
var url = new Uri(urlString);
|
||||
var peerSrc = await _peerResolver.TryResolveAsync(
|
||||
row.Version, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256, ct);
|
||||
Uri url;
|
||||
if (peerSrc is not null)
|
||||
{
|
||||
url = peerSrc.ZipUrl;
|
||||
_currentPeerHost = peerSrc.PeerHost;
|
||||
StatusMessage = Strings.StatusDownloadingFromPeer(peerSrc.PeerHost, row.Version);
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentPeerHost = null;
|
||||
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
|
||||
var urlString = signed ?? row.Remote.Download.Url;
|
||||
url = new Uri(urlString);
|
||||
}
|
||||
// Callback de refresh d'URL : appelé par DownloadManager quand un segment
|
||||
// reçoit 403/410 (URL HMAC expirée mid-DL). Permet aux DLs très longs sur
|
||||
// connexion lente (>= 6 h, le TTL serveur) de continuer transparemment.
|
||||
// Si la license a été révoquée entre-temps, le callback renvoie null et le
|
||||
// DL fail proprement.
|
||||
// reçoit 403/410 (URL HMAC expirée mid-DL). Pour un peer LAN l'URL n'expire
|
||||
// jamais, on retourne juste la même. Pour OVH, on rappelle GetSignedDownloadUrl
|
||||
// (le TTL serveur HMAC est de 6 h, suffit pour la plupart des DL mais pas pour
|
||||
// les très lentes connexions ADSL).
|
||||
async Task<Uri?> RefreshUrlAsync(CancellationToken c)
|
||||
{
|
||||
if (peerSrc is not null) return peerSrc.ZipUrl;
|
||||
var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, c);
|
||||
return fresh is null ? null : new Uri(fresh);
|
||||
}
|
||||
@@ -975,7 +1051,20 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
});
|
||||
await _zipInstaller.InstallAsync(zipPath, target, installProgress, ct);
|
||||
|
||||
try { File.Delete(zipPath); } catch { /* non critique */ }
|
||||
// Promote le ZIP au cache LAN (sidecar SHA écrit) puis prune les vieilles
|
||||
// versions selon MaxCachedVersions. Le ZIP est déjà au bon endroit
|
||||
// (DownloadManager.VerifyAndFinalizeAsync l'a Move'd vers `proserve-X.zip`),
|
||||
// donc PromoteAsync est essentiellement un no-op + écriture du sidecar.
|
||||
// Les versions encore installées sont protégées du pruning.
|
||||
try
|
||||
{
|
||||
await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct);
|
||||
await _zipCacheStore.PruneAsync(_config.LanCache.MaxCachedVersions, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ZIP cache promote/prune failed (non-critical)");
|
||||
}
|
||||
|
||||
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
|
||||
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
|
||||
@@ -1186,6 +1275,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_activeDownloadCts = null;
|
||||
_activeRow = null;
|
||||
_activeInstallTask = null;
|
||||
_currentPeerHost = null;
|
||||
IsBusy = false;
|
||||
IsDownloadActive = false;
|
||||
}
|
||||
@@ -1291,7 +1381,12 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append("⬇ v").Append(row.Version).Append(" : ");
|
||||
// Préfixe différencié pour rendre la source du DL visible en permanence
|
||||
// dans le footer : 📡 LAN (avec IP du peer) vs ⬇ Internet (OVH).
|
||||
if (_currentPeerHost is not null)
|
||||
sb.Append("📡 LAN ").Append(_currentPeerHost).Append(" • v").Append(row.Version).Append(" : ");
|
||||
else
|
||||
sb.Append("⬇ v").Append(row.Version).Append(" : ");
|
||||
sb.Append(FormatSize(p.BytesDownloaded));
|
||||
if (p.TotalBytes > 0) sb.Append(" / ").Append(FormatSize(p.TotalBytes));
|
||||
if (p.BytesPerSecond > 0) sb.Append(" • ").Append(FormatSize((long)p.BytesPerSecond)).Append("/s");
|
||||
|
||||
@@ -10,6 +10,7 @@ using PSLauncher.App.Views;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using PSLauncher.Core.ApiTool;
|
||||
using PSLauncher.Core.DocTool;
|
||||
using PSLauncher.Core.Lan;
|
||||
using PSLauncher.Core.Downloads;
|
||||
using PSLauncher.Core.Installations;
|
||||
using PSLauncher.Core.Licensing;
|
||||
@@ -37,6 +38,9 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
private readonly IReportToolDeployer _reportDeployer;
|
||||
private readonly IDocToolDeployer _docDeployer;
|
||||
private readonly IApiToolDeployer _apiDeployer;
|
||||
private readonly ILanCacheServer _lanCacheServer;
|
||||
private readonly ILanDiscoveryService _lanDiscovery;
|
||||
private readonly IZipCacheStore _zipCacheStore;
|
||||
private readonly IInstallationRegistry _registry;
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<SettingsViewModel> _logger;
|
||||
@@ -98,6 +102,20 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
= new();
|
||||
public bool HasApiBackups => ApiBackups.Count > 0;
|
||||
|
||||
// ----- Cache LAN P2P -----
|
||||
[ObservableProperty] private bool _lanServerEnabled;
|
||||
[ObservableProperty] private bool _lanClientEnabled;
|
||||
[ObservableProperty] private int _lanServerPort;
|
||||
[ObservableProperty] private int _lanDiscoveryPort;
|
||||
[ObservableProperty] private int _lanMaxCachedVersions;
|
||||
/// <summary>Multi-line text bindé sur un TextBox : 1 URL par ligne.</summary>
|
||||
[ObservableProperty] private string _lanManualPeers = string.Empty;
|
||||
/// <summary>Statut serveur affiché (rafraîchi à l'ouverture des Settings et toutes les 5 s).</summary>
|
||||
[ObservableProperty] private string _lanServerStatus = string.Empty;
|
||||
/// <summary>Liste read-only des peers découverts via UDP, formaté pour l'UI.</summary>
|
||||
public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new();
|
||||
public bool HasLanDiscoveredPeers => LanDiscoveredPeers.Count > 0;
|
||||
|
||||
// ----- Health checks (bandeau de santé système) -----
|
||||
/// <summary>
|
||||
/// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne
|
||||
@@ -179,6 +197,9 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
IReportToolDeployer reportDeployer,
|
||||
IDocToolDeployer docDeployer,
|
||||
IApiToolDeployer apiDeployer,
|
||||
ILanCacheServer lanCacheServer,
|
||||
ILanDiscoveryService lanDiscovery,
|
||||
IZipCacheStore zipCacheStore,
|
||||
IInstallationRegistry registry,
|
||||
HttpClient http,
|
||||
ILogger<SettingsViewModel> logger)
|
||||
@@ -191,6 +212,9 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_reportDeployer = reportDeployer;
|
||||
_docDeployer = docDeployer;
|
||||
_apiDeployer = apiDeployer;
|
||||
_lanCacheServer = lanCacheServer;
|
||||
_lanDiscovery = lanDiscovery;
|
||||
_zipCacheStore = zipCacheStore;
|
||||
_registry = registry;
|
||||
_http = http;
|
||||
_logger = logger;
|
||||
@@ -224,6 +248,14 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_apiAutoDeploy = config.ApiTool.AutoDeploy;
|
||||
_apiMaxBackups = config.ApiTool.MaxBackups;
|
||||
|
||||
_lanServerEnabled = config.LanCache.ServerEnabled;
|
||||
_lanClientEnabled = config.LanCache.ClientEnabled;
|
||||
_lanServerPort = config.LanCache.ServerPort;
|
||||
_lanDiscoveryPort = config.LanCache.DiscoveryPort;
|
||||
_lanMaxCachedVersions = config.LanCache.MaxCachedVersions;
|
||||
_lanManualPeers = string.Join(Environment.NewLine, config.LanCache.ManualPeerUrls ?? new List<string>());
|
||||
RefreshLanStatus();
|
||||
|
||||
// Snapshot éditable des health checks. On clone chaque entry pour ne pas
|
||||
// muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save.
|
||||
foreach (var entry in config.HealthChecks.Checks)
|
||||
@@ -337,6 +369,18 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
(Language ?? "auto").ToLowerInvariant(),
|
||||
StringComparison.Ordinal);
|
||||
|
||||
// Détecte les changements LAN qui nécessitent un restart du launcher.
|
||||
// Les hosted services (LanCacheServer, LanDiscoveryService) lisent leur
|
||||
// config UNE FOIS au démarrage de l'app — toggler ces flags après coup
|
||||
// ne réveille pas le service mort. Le port aussi : changer pendant que
|
||||
// le serveur tourne ne re-bind pas. ManualPeerUrls et MaxCachedVersions
|
||||
// sont lus à chaque appel donc OK sans restart.
|
||||
var lanRestartNeeded =
|
||||
_config.LanCache.ServerEnabled != LanServerEnabled
|
||||
|| _config.LanCache.ClientEnabled != LanClientEnabled
|
||||
|| (LanServerEnabled && _config.LanCache.ServerPort != LanServerPort)
|
||||
|| ((LanServerEnabled || LanClientEnabled) && _config.LanCache.DiscoveryPort != LanDiscoveryPort);
|
||||
|
||||
_config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/');
|
||||
_config.InstallRoot = InstallRoot;
|
||||
_config.Language = Language ?? "auto";
|
||||
@@ -369,6 +413,17 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
|
||||
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);
|
||||
|
||||
_config.LanCache.ServerEnabled = LanServerEnabled;
|
||||
_config.LanCache.ClientEnabled = LanClientEnabled;
|
||||
_config.LanCache.ServerPort = LanServerPort > 0 && LanServerPort <= 65535 ? LanServerPort : 47623;
|
||||
_config.LanCache.DiscoveryPort = LanDiscoveryPort > 0 && LanDiscoveryPort <= 65535 ? LanDiscoveryPort : 47624;
|
||||
_config.LanCache.MaxCachedVersions = Math.Max(0, LanMaxCachedVersions);
|
||||
_config.LanCache.ManualPeerUrls = (LanManualPeers ?? string.Empty)
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 0)
|
||||
.ToList();
|
||||
|
||||
// Health checks : on remplace intégralement la liste persistée par
|
||||
// les copies éditées. Les originaux restent en mémoire de toute façon
|
||||
// (les VM les ont clonés), donc Cancel a déjà préservé l'état initial.
|
||||
@@ -377,36 +432,42 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_configStore.Save(_config);
|
||||
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
|
||||
|
||||
// Helper local : prompt + spawn cmd qui attend la fermeture du launcher puis relance.
|
||||
void PromptRestart(string message, string boxTitle, string logContext)
|
||||
{
|
||||
var ok = ThemedMessageBox.Show(
|
||||
message, boxTitle,
|
||||
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
|
||||
if (ok != MessageBoxResult.OK) return;
|
||||
try
|
||||
{
|
||||
var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
|
||||
if (string.IsNullOrEmpty(exe)) return;
|
||||
var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\"";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = cmd,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
Application.Current.Shutdown();
|
||||
}
|
||||
catch (Exception ex) { _logger.LogError(ex, "Restart on {Context} failed", logContext); }
|
||||
}
|
||||
|
||||
if (languageChanged)
|
||||
{
|
||||
// Le launcher doit être relancé pour que les bindings x:Static reflètent
|
||||
// la nouvelle langue. Plutôt que de demander à l'utilisateur de fermer/rouvrir,
|
||||
// on spawne un cmd qui attend la fermeture puis relance.
|
||||
var ok = ThemedMessageBox.Show(
|
||||
Strings.MsgLanguageRestart,
|
||||
Strings.MsgBoxLanguageChange,
|
||||
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
|
||||
if (ok == MessageBoxResult.OK)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
|
||||
if (!string.IsNullOrEmpty(exe))
|
||||
{
|
||||
var pid = Environment.ProcessId;
|
||||
var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\"";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = cmd,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
Application.Current.Shutdown();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { _logger.LogError(ex, "Restart on language change failed"); }
|
||||
}
|
||||
// la nouvelle langue. (Si LAN a aussi changé, le même restart le couvre.)
|
||||
PromptRestart(Strings.MsgLanguageRestart, Strings.MsgBoxLanguageChange, "language change");
|
||||
}
|
||||
else if (lanRestartNeeded)
|
||||
{
|
||||
// Les hosted services LAN lisent leur config au démarrage uniquement.
|
||||
// Sans restart, toggler ServerEnabled / ClientEnabled ne réveille pas
|
||||
// le service mort.
|
||||
PromptRestart(Strings.MsgLanCacheRestart, Strings.MsgBoxLanCacheChange, "LAN cache toggle");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -857,6 +918,38 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== CACHE LAN P2P =====================
|
||||
|
||||
/// <summary>
|
||||
/// Rafraîchit les indicateurs LAN (statut serveur, liste des peers découverts).
|
||||
/// Appelé à l'init + via la commande "Actualiser" dans la UI.
|
||||
/// </summary>
|
||||
[RelayCommand]
|
||||
private void RefreshLanStatus()
|
||||
{
|
||||
// Statut serveur
|
||||
if (_lanCacheServer.IsRunning && _lanCacheServer.BoundEndpoints.Count > 0)
|
||||
{
|
||||
var versions = _zipCacheStore.ListCached().Count;
|
||||
LanServerStatus = Strings.SettingsLanCacheServerStatus(
|
||||
string.Join(", ", _lanCacheServer.BoundEndpoints), versions);
|
||||
}
|
||||
else
|
||||
{
|
||||
LanServerStatus = Strings.SettingsLanCacheServerOff;
|
||||
}
|
||||
|
||||
// Liste des peers découverts (formaté pour l'affichage)
|
||||
var peers = _lanDiscovery.ListDiscovered();
|
||||
LanDiscoveredPeers.Clear();
|
||||
foreach (var p in peers)
|
||||
{
|
||||
var versions = p.Versions.Count > 0 ? string.Join(", ", p.Versions) : "—";
|
||||
LanDiscoveredPeers.Add($"http://{p.Host}:{p.Port} • versions: [{versions}]");
|
||||
}
|
||||
OnPropertyChanged(nameof(HasLanDiscoveredPeers));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RedeployApiAsync()
|
||||
{
|
||||
|
||||
@@ -688,6 +688,9 @@
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||
Text="PS_Launcher"
|
||||
@@ -707,6 +710,35 @@
|
||||
Text="{Binding LocalIpAddress}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="11" Margin="0,2,0,0" />
|
||||
<!-- Statut LAN P2P, refresh auto toutes les 10 s par DispatcherTimer
|
||||
dans MainViewModel. ON / OFF (—) / KO (config ON mais bind échoué). -->
|
||||
<TextBlock Grid.Row="2" Grid.Column="0"
|
||||
Text="Serveur"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="11" Margin="0,2,8,0"
|
||||
ToolTip="État du serveur LAN P2P (partage des ZIPs aux peers du LAN)" />
|
||||
<TextBlock Grid.Row="2" Grid.Column="1"
|
||||
Text="{Binding LanServerStatus}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="11" Margin="0,2,0,0" />
|
||||
<TextBlock Grid.Row="3" Grid.Column="0"
|
||||
Text="Client"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="11" Margin="0,2,8,0"
|
||||
ToolTip="Mode client : tente d'aller chercher les ZIPs sur le LAN avant OVH" />
|
||||
<TextBlock Grid.Row="3" Grid.Column="1"
|
||||
Text="{Binding LanClientStatus}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="11" Margin="0,2,0,0" />
|
||||
<TextBlock Grid.Row="4" Grid.Column="0"
|
||||
Text="Peers"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="11" Margin="0,2,8,0"
|
||||
ToolTip="Nombre de peers LAN découverts auto via UDP (TTL 60 s)" />
|
||||
<TextBlock Grid.Row="4" Grid.Column="1"
|
||||
Text="{Binding LanPeerCount}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="11" Margin="0,2,0,0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -648,6 +648,125 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Cache LAN P2P (peers du LAN partagent les ZIPs entre eux) -->
|
||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="20" Margin="0,0,0,16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{x:Static loc:Strings.SettingsLanCache}"
|
||||
FontSize="11" FontWeight="Bold"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<CheckBox IsChecked="{Binding LanServerEnabled}"
|
||||
Margin="0,4,0,0"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
Content="{x:Static loc:Strings.SettingsLanCacheServerEnabled}" />
|
||||
<CheckBox IsChecked="{Binding LanClientEnabled}"
|
||||
Margin="0,4,0,0"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
Content="{x:Static loc:Strings.SettingsLanCacheClientEnabled}" />
|
||||
|
||||
<Grid Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="100" />
|
||||
<ColumnDefinition Width="12" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="100" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{x:Static loc:Strings.SettingsLanCacheServerPort}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="12" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1"
|
||||
Text="{Binding LanServerPort, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||
<TextBlock Grid.Column="3"
|
||||
Text="{x:Static loc:Strings.SettingsLanCacheDiscoveryPort}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="12" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="4"
|
||||
Text="{Binding LanDiscoveryPort, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||
</Grid>
|
||||
|
||||
<Grid Margin="0,8,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="100" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{x:Static loc:Strings.SettingsLanCacheMaxCached}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="12" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1"
|
||||
Text="{Binding LanMaxCachedVersions, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||
</Grid>
|
||||
|
||||
<!-- Status serveur (read-only, alimenté par RefreshLanStatus) -->
|
||||
<Grid Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{Binding LanServerStatus}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="12" VerticalAlignment="Center"
|
||||
TextWrapping="Wrap" />
|
||||
<Button Grid.Column="1"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="↻"
|
||||
Command="{Binding RefreshLanStatusCommand}"
|
||||
ToolTip="Actualiser le statut + la liste des peers"
|
||||
Padding="12,6" />
|
||||
</Grid>
|
||||
|
||||
<!-- Peers découverts auto (read-only) -->
|
||||
<TextBlock Text="{x:Static loc:Strings.SettingsLanCacheDiscoveredPeers}"
|
||||
FontWeight="Bold" FontSize="11"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,16,0,6" />
|
||||
<TextBlock Text="{x:Static loc:Strings.SettingsLanCacheNoDiscovered}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="12" FontStyle="Italic"
|
||||
Visibility="{Binding HasLanDiscoveredPeers, Converter={StaticResource InverseBoolToVisibility}}" />
|
||||
<ItemsControl ItemsSource="{Binding LanDiscoveredPeers}"
|
||||
Visibility="{Binding HasLanDiscoveredPeers, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#1A1A20"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
CornerRadius="4" Padding="10,6" Margin="0,0,0,4">
|
||||
<TextBlock Text="{Binding}"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
FontFamily="Consolas" FontSize="11" />
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- Peers manuels (1 URL par ligne, multiline TextBox) -->
|
||||
<TextBlock Text="{x:Static loc:Strings.SettingsLanCacheManualPeers}"
|
||||
FontWeight="Bold" FontSize="11"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,16,0,6" />
|
||||
<TextBox Text="{Binding LanManualPeers, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||
FontFamily="Consolas" FontSize="11"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="NoWrap"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
MinHeight="60" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Outil Doc (XAMPP htdocs deploy, mirror of Report) -->
|
||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
|
||||
Reference in New Issue
Block a user