v0.28.10 — Mode auto + settings lock par license + fix post-install state

Mode auto (auto-launch d'une version désignée au démarrage et après exit) :
 - Master toggle + version désignée via bouton AUTO (orange) sur chaque row
 - Countdown 5 s configurable avec popup d'annulation (StartAutoLaunchCountdown)
 - Args CLI passés à PROSERVE : opérateur tape juste le nom (autoconnect, login),
   le launcher ajoute -/=/quotes. Format produit identique à un .bat.
   Fix : passage de ArgumentList à Arguments string pour éviter le quotage auto
   .NET sur les args contenant '=' (cassait FParse::Value).
 - Garde-fou anti double-lancement : refuse de lancer si _runningProserve vivant
   OU IProcessLauncher.IsRunning() détecte une instance externe. Silent pour
   les triggers auto, popup pour les clics manuels.
 - Option « Attendre santé système » : countdown différé tant que les health
   checks ne sont pas tous OK (phase 1 du dialog avec liste des pending).

Settings lock par license (remplace l'ancien lock global manifest) :
 - Colonne settings_lock_password_hash sur table licenses (migration 003)
 - Backoffice : bouton 🔒 par license pour set/clear hash SHA-256
 - Payload /license/validate inclut settingsLockPasswordHash (v3 canonique)
 - 3-tier fallback signature : v3 → v2 → legacy pour compat cache offline
 - SettingsLockService : in-memory unlock state, gate l'expander Avancés
 - SettingsLockDialog : prompt mdp, persiste déverrouillé jusqu'au restart

Fix post-install : la row restait en visuel « Installing » jusqu'au prochain
Check Updates. Reset explicite row.State=InstalledIdle + _activeRow=null
AVANT le RebuildList post-install pour purger l'instance orpheline.

Migrations :
 - 002_channel_betas.sql réécrit en ALTER simples (DELIMITER cassait
   migrate.php qui split sur ';\n')
 - migrate.php tolère « Duplicate column/key name » comme idempotent

Strings (5 langues, ~25 nouvelles) :
 - Renomme « Relance automatique » → « Lancement automatique » (dialog
   utilisé pour les 3 entry points : clic, startup, post-exit)
 - Tooltips auto-mode, settings lock prompts, health wait phase, etc.

Bumps : 0.27.4 → 0.28.10 (csproj + .iss).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 16:56:55 +02:00
parent 9b17dfa84c
commit efb53f079e
31 changed files with 1994 additions and 77 deletions

View File

@@ -16,6 +16,7 @@ using PSLauncher.Core.Integrity;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests;
using PSLauncher.Core.Security;
using PSLauncher.Core.ApiTool;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Lan;
@@ -33,6 +34,13 @@ namespace PSLauncher.App;
public partial class App : Application
{
private IHost? _host;
/// <summary>
/// IServiceProvider du host DI. Exposé pour les rares cas où une vue est
/// instanciée par <c>new</c> hors du container (ex: SettingsDialog) et a
/// besoin de résoudre un service.
/// </summary>
public IServiceProvider? HostServices => _host?.Services;
private static System.Threading.Mutex? _singleInstanceMutex;
private const string SingleInstanceMutexName = "Global\\PSLauncher-3F8E2C1A-9B47-4D5E-A0F8-2E9D1B6C7A3F";
@@ -175,6 +183,7 @@ public partial class App : Application
services.AddSingleton<IDownloadStateStore, DownloadStateStore>();
services.AddSingleton<IManifestCache, ManifestCache>();
services.AddSingleton<ISettingsLockService, SettingsLockService>();
services.AddSingleton<IManifestService>(sp =>
new ManifestService(
sp.GetRequiredService<HttpClient>(),
@@ -186,6 +195,7 @@ public partial class App : Application
() => sp.GetRequiredService<ILicenseService>().GetCached()?.Channel,
sp.GetRequiredService<IManifestCache>(),
sp.GetRequiredService<IPeerManifestFetcher>(),
sp.GetRequiredService<ISettingsLockService>(),
sp.GetRequiredService<ILogger<ManifestService>>()));
services.AddSingleton<IDownloadManager, DownloadManager>();
@@ -198,6 +208,7 @@ public partial class App : Application
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
sp.GetRequiredService<IConfigStore>(),
sp.GetRequiredService<LocalConfig>(),
sp.GetRequiredService<ISettingsLockService>(),
sp.GetRequiredService<ILogger<LicenseService>>()));
services.AddSingleton<IToastService, ToastService>();

View File

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

View File

@@ -80,6 +80,17 @@ public sealed partial class MainViewModel : ObservableObject
/// </summary>
private Task? _activeInstallTask;
/// <summary>
/// Référence au process PROSERVE lancé par le launcher (la PLUS RÉCENTE).
/// Sert de garde-fou contre les double-lancements : avant chaque
/// <see cref="LaunchVersion"/>, on vérifie que ce champ est null ou que le
/// process a terminé. Le hook Exited remet le champ à null pour autoriser
/// le prochain lancement (manuel ou auto). Couvre uniquement les instances
/// LANCÉES PAR NOUS — pour détecter une instance lancée hors launcher, on
/// délègue à <see cref="IProcessLauncher.IsRunning(InstalledVersion)"/>.
/// </summary>
private System.Diagnostics.Process? _runningProserve;
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
/// <summary>
@@ -430,6 +441,14 @@ public sealed partial class MainViewModel : ObservableObject
},
Application.Current.Dispatcher);
_lanInfoRefreshTimer.Start();
// Mode auto : si une version est désignée + feature activée, on la lance
// automatiquement après que la UI soit prête (Dispatcher.BeginInvoke différé
// pour que MainWindow ait fini son OnLoaded). Le grace period via popup modal
// sera affiché par-dessus la fenêtre normalement.
Application.Current.Dispatcher.BeginInvoke(
new Action(TryAutoLaunchAtStartup),
System.Windows.Threading.DispatcherPriority.Background);
}
/// <summary>
@@ -607,6 +626,72 @@ public sealed partial class MainViewModel : ObservableObject
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
row.OpenFolderHandler = OpenVersionFolder;
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
row.ToggleAutoModeHandler = ToggleAutoModeForRow;
// Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList)
row.ShowAutoButton = _config.AutoMode.FeatureEnabled && row.IsInstalled;
row.IsAutoModeSelected = string.Equals(
_config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal);
}
/// <summary>
/// Click sur le bouton AUTO d'une row. Toggle l'état sur cette row uniquement,
/// désélectionne toutes les autres (unicité), persiste la config. Si on toggle
/// OFF la version actuellement sélectionnée, SelectedVersion devient null
/// (= mode auto inactif mais feature toujours activée pour permettre une nouvelle
/// sélection plus tard).
/// </summary>
private void ToggleAutoModeForRow(VersionRowViewModel row)
{
// Trace fichier (en plus du Serilog principal) pour pouvoir diagnostiquer le
// path d'invocation depuis le bouton AUTO sans dépendre du logger DI.
try
{
var dir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "logs");
System.IO.Directory.CreateDirectory(dir);
System.IO.File.AppendAllText(
System.IO.Path.Combine(dir, "auto-mode.log"),
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z ToggleAutoModeForRow v{row.Version} (FeatureEnabled={_config.AutoMode.FeatureEnabled}, IsInstalled={row.IsInstalled}, current SelectedVersion={_config.AutoMode.SelectedVersion}){Environment.NewLine}");
}
catch { }
if (!_config.AutoMode.FeatureEnabled)
{
_logger.LogInformation("Auto-mode toggle ignored : FeatureEnabled=false");
return;
}
if (!row.IsInstalled)
{
_logger.LogInformation("Auto-mode toggle ignored : v{Version} pas installée", row.Version);
return;
}
var newSelected = !row.IsAutoModeSelected;
// Désélectionne toutes les autres
if (FeaturedVersion is not null)
FeaturedVersion.IsAutoModeSelected = false;
foreach (var r in OtherVersions)
r.IsAutoModeSelected = false;
// Sélectionne celle-ci (ou laisse désélectionnée si on a toggle OFF)
row.IsAutoModeSelected = newSelected;
_config.AutoMode.SelectedVersion = newSelected ? row.Version : null;
_configStore.Save(_config);
_logger.LogInformation("Auto-mode selection: {Version}",
newSelected ? row.Version : "(none)");
// Si on vient d'activer le mode auto sur cette version, déclenche immédiatement
// le countdown + lancement. Différé via Dispatcher.BeginInvoke pour que le
// bouton ait le temps de repeindre en orange AVANT que la modal apparaisse.
if (newSelected)
{
Application.Current.Dispatcher.BeginInvoke(
new Action(() => StartAutoLaunchCountdown(row)),
System.Windows.Threading.DispatcherPriority.Background);
}
}
/// <summary>
@@ -897,9 +982,48 @@ public sealed partial class MainViewModel : ObservableObject
private void LaunchVersion(VersionRowViewModel row)
{
if (row.Installed is null) return;
// ---- Garde-fou anti double-lancement ----
// Deux niveaux de vérif pour couvrir tous les cas :
// 1. _runningProserve : process LANCÉ PAR NOUS encore vivant ⇒ silently
// refuse (cas typique : exit event glitché, ou double-clic du bouton
// LANCER, ou auto-mode qui re-déclenche pendant qu'on tourne déjà).
// 2. _processLauncher.IsRunning(row.Installed) : scan système pour
// détecter une instance lancée HORS launcher (ex. opérateur a
// double-cliqué le .exe dans l'explorateur).
// En cas de match, on log + popup. La popup n'apparaît PAS pour les
// déclenchements auto-mode (silently skip) pour ne pas spammer.
if (IsAnyProserveAlreadyRunning(row.Installed, out var detail))
{
_logger.LogWarning(
"Refusing to launch PROSERVE v{Version} : an instance is already running ({Detail})",
row.Version, detail);
// Distinction manuel vs auto-mode : pour un clic LANCER manuel,
// l'opérateur doit savoir pourquoi rien ne se passe ⇒ popup.
// Pour un auto-trigger (countdown finish, exit retrigger…), silently
// skip suffit — pas d'interaction utilisateur en cours.
if (!_isAutoLaunchInFlight)
{
ThemedMessageBox.Show(
Strings.MsgAlreadyRunningBody,
Strings.MsgAlreadyRunningTitle,
MessageBoxButton.OK, MessageBoxImage.Warning);
}
return;
}
try
{
var proc = _processLauncher.Launch(row.Installed);
// Si on lance la version désignée comme "auto", on passe les CLI args
// configurés (ex: -autoconnect -login=Jerome2 -password=… -ipserver=10.0.4.100).
// Pour les lancements manuels d'autres versions, args = null (comportement standard).
var isAutoVersion = _config.AutoMode.FeatureEnabled
&& !string.IsNullOrEmpty(_config.AutoMode.SelectedVersion)
&& string.Equals(_config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal);
string[]? cliArgs = isAutoVersion ? BuildAutoModeCliArgs(_config.AutoMode.Args) : null;
var proc = _processLauncher.Launch(row.Installed, cliArgs);
_runningProserve = proc;
_config.LastLaunchedVersion = row.Version;
_configStore.Save(_config);
@@ -917,6 +1041,13 @@ public sealed partial class MainViewModel : ObservableObject
_logger.LogInformation("PROSERVE v{Version} a quitté — restauration du launcher", row.Version);
Application.Current?.Dispatcher.Invoke(() =>
{
// Libère le slot AVANT de potentiellement relancer en mode auto
// (sinon le garde-fou ci-dessus refuserait le nouveau launch).
// Compare la référence pour éviter de nuller un process plus récent
// dans un scénario rare où le user a relancé manuellement entre temps.
if (ReferenceEquals(_runningProserve, proc))
_runningProserve = null;
if (Application.Current.MainWindow is { } main)
{
if (main.WindowState == WindowState.Minimized)
@@ -926,23 +1057,252 @@ public sealed partial class MainViewModel : ObservableObject
main.Topmost = true;
main.Topmost = false;
}
// Si la version qui vient de quitter est la version auto, on
// déclenche le countdown de relance. L'opérateur peut annuler
// (désactive le mode auto sur cette session) ou laisser le timer
// expirer pour relancer.
if (isAutoVersion)
StartAutoLaunchCountdown(row);
});
};
}
catch (Exception ex)
{
// ShellExecute peut renvoyer un Process sans accès aux events — non bloquant
// ShellExecute peut renvoyer un Process sans accès aux events — non bloquant.
// Conséquence : _runningProserve ne sera jamais nullé via Exited et resterait
// bloquant. On clear immédiatement pour ne pas verrouiller les futurs launches
// (au pire on perd la protection sur cette instance, mais IsRunning() scanne
// le système et catchera quand même les doublons).
_logger.LogDebug(ex, "Could not hook Exited on launched process");
_runningProserve = null;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Launch failed");
_runningProserve = null;
ThemedMessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error);
}
}
/// <summary>
/// True quand un lancement auto-mode est en cours de déclenchement (countdown
/// en cours OU appel <see cref="LaunchVersion"/> issu du dialog). Sert à
/// rendre le garde-fou silencieux pour les triggers auto (sinon une popup
/// "déjà lancé" apparaîtrait à chaque tick d'un exit-event glitché en mode
/// auto). Toggled par <see cref="StartAutoLaunchCountdown"/>.
/// </summary>
private bool _isAutoLaunchInFlight;
/// <summary>
/// True si une instance PROSERVE tourne déjà — soit lancée par nous
/// (<see cref="_runningProserve"/> encore vivant), soit lancée hors launcher
/// et détectée via <see cref="IProcessLauncher.IsRunning"/>. Le paramètre
/// <paramref name="detail"/> est rempli avec le motif de blocage pour les logs.
/// </summary>
private bool IsAnyProserveAlreadyRunning(InstalledVersion target, out string detail)
{
// 1) Notre process tracking
if (_runningProserve is { } p)
{
try
{
if (!p.HasExited)
{
detail = $"_runningProserve PID={p.Id} still alive";
return true;
}
// Process a terminé mais Exited n'a pas (encore) fired ⇒ on
// libère le slot et continue à vérifier le système.
_runningProserve = null;
}
catch (InvalidOperationException)
{
// Process déjà disposé → considère terminé
_runningProserve = null;
}
catch (Exception ex)
{
// Sandboxing ou perte d'accès au process : on ne peut pas savoir.
// Mieux vaut être permissif (laisser passer) que bloquer à tort.
_logger.LogDebug(ex, "Could not poll _runningProserve.HasExited, treating as exited");
_runningProserve = null;
}
}
// 2) Scan système pour les instances externes (ou nôtres dont on a perdu
// le handle, ex. après un crash launcher partiel).
try
{
if (_processLauncher.IsRunning(target))
{
detail = $"system scan detected running {Path.GetFileName(target.ExecutablePath)}";
return true;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "IsRunning() scan failed, treating as not running");
}
detail = string.Empty;
return false;
}
/// <summary>
/// Convertit la liste <see cref="AutoModeConfig.Args"/> en string[] format CLI
/// compatible Unreal Engine (FParse::Param et FParse::Value).
///
/// L'opérateur saisit la liste de paires {Key, Value} sans avoir à se soucier
/// de la syntaxe : il tape <c>autoconnect</c> (sans tiret) et <c>login</c> /
/// <c>Jerome</c>. Le launcher se charge d'ajouter les <c>-</c>, <c>=</c> et
/// le quotage des valeurs avec espaces.
///
/// Règles de formatage produites :
/// • Value vide → <c>-{key}</c> (flag : FParse::Param)
/// • Value sans espace → <c>-{key}={value}</c> (FParse::Value classique)
/// • Value avec espace → <c>-{key}="value spaced"</c> (quotage Win32)
///
/// Défensif : on strip un éventuel <c>-</c> en tête de Key et un <c>=</c> en
/// tête de Value au cas où l'opérateur les a saisis dans l'UI ("au cas où" =
/// retour utilisateur : certains tapent <c>-login</c> ou <c>=Jerome</c> par
/// réflexe et obtenaient <c>--login</c> ou <c>-login==Jerome</c> à l'exécution).
/// Les clés vides sont skippées silencieusement.
/// </summary>
private static string[] BuildAutoModeCliArgs(IEnumerable<AutoModeArg> args)
{
var list = new List<string>();
foreach (var a in args)
{
// Sanitize Key : trim whitespace + leading dashes éventuels.
var key = (a.Key ?? string.Empty).Trim().TrimStart('-').Trim();
if (string.IsNullOrEmpty(key)) continue;
// Sanitize Value : trim whitespace + un éventuel `=` en tête.
var val = (a.Value ?? string.Empty).Trim().TrimStart('=').Trim();
if (string.IsNullOrEmpty(val))
{
list.Add($"-{key}");
}
else if (val.IndexOfAny(new[] { ' ', '\t', '"' }) >= 0)
{
// Quote + escape les " internes (rare mais possible pour un mdp).
var escaped = val.Replace("\"", "\\\"");
list.Add($"-{key}=\"{escaped}\"");
}
else
{
list.Add($"-{key}={val}");
}
}
return list.ToArray();
}
/// <summary>
/// Point d'entrée UNIQUE pour le lancement auto avec countdown. Utilisé par :
/// 1. Clic sur bouton AUTO → désigne la version + lance après countdown
/// 2. Startup du launcher → si une version était désignée, idem countdown + launch
/// 3. Exit de PROSERVE en mode auto → countdown + relance
///
/// Comportement :
/// - Affiche un popup modal avec compte-à-rebours (3-5 s configurables)
/// - Countdown expire → <see cref="LaunchVersion"/> avec args CLI mode auto
/// - Annuler → désactive le mode auto (<c>SelectedVersion=null</c>, bouton AUTO
/// repasse en gris sur la row CURRENT, persisté dans config.json)
/// </summary>
private void StartAutoLaunchCountdown(VersionRowViewModel row)
{
if (row.Installed is null) return;
// Garde-fou tôt : si PROSERVE est déjà lancé, ne pas afficher de countdown.
// Sinon le countdown défile → LaunchVersion → refus silencieux → confusing.
// On log simplement et on désactive le mode auto pour cette session pour
// que l'opérateur reprenne explicitement la main (sinon un exit-event
// glitché pourrait re-trigger ce path en boucle).
if (IsAnyProserveAlreadyRunning(row.Installed, out var why))
{
_logger.LogInformation(
"Auto-launch countdown skipped for v{Version} : already running ({Why})",
row.Version, why);
return;
}
var grace = Math.Clamp(_config.AutoMode.GracePeriodSeconds, 1, 60);
// Callback santé : non-null SEULEMENT si l'option WaitForHealthChecks
// est activée ET qu'il y a au moins un indicateur configuré. Sinon le
// dialog saute la phase santé et démarre direct le countdown.
Func<IReadOnlyList<string>>? healthSnapshot = null;
if (_config.AutoMode.WaitForHealthChecks && HealthIndicators.Count > 0)
{
healthSnapshot = () => HealthIndicators
.Where(h => h.Severity != Core.Health.HealthSeverity.Ok)
.Select(h => h.Name)
.ToList();
}
_isAutoLaunchInFlight = true;
try
{
var dialog = new Views.AutoRelaunchDialog(row.Version, grace, healthSnapshot)
{
Owner = Application.Current.MainWindow,
};
var result = dialog.ShowDialog();
if (result == true)
{
_logger.LogInformation("Auto-launching PROSERVE v{Version}", row.Version);
LaunchVersion(row);
}
else
{
// Cancel : désactive le mode auto sur cette version. L'opérateur peut
// reprendre la main et re-cliquer plus tard pour réactiver.
_logger.LogInformation("Auto-launch cancelled by user for v{Version}", row.Version);
_config.AutoMode.SelectedVersion = null;
_configStore.Save(_config);
// Trouve la row CURRENT (RebuildList a pu remplacer l'instance entre temps
// — auquel cas `row` est orpheline et le bouton de la UI ne se mettrait
// pas à jour). Pour le visuel, on désélectionne TOUTES les rows par sûreté.
if (FeaturedVersion is not null)
FeaturedVersion.IsAutoModeSelected = false;
foreach (var r in OtherVersions)
r.IsAutoModeSelected = false;
}
}
finally
{
_isAutoLaunchInFlight = false;
}
}
/// <summary>
/// Au démarrage du launcher, si le mode auto est configuré avec une version
/// sélectionnée ET que cette version est bien installée, on déclenche le
/// countdown + lancement (avec possibilité d'annuler via Cancel sur la modal).
/// Appelé depuis le constructeur après RebuildList().
/// </summary>
private void TryAutoLaunchAtStartup()
{
if (!_config.AutoMode.FeatureEnabled) return;
var target = _config.AutoMode.SelectedVersion;
if (string.IsNullOrEmpty(target)) return;
var row = (FeaturedVersion?.Version == target ? FeaturedVersion : null)
?? OtherVersions.FirstOrDefault(r => r.Version == target);
if (row is null || !row.IsInstalled)
{
_logger.LogInformation("Auto-launch skipped: v{Version} not installed locally", target);
return;
}
_logger.LogInformation("Auto-launching v{Version} at startup (with countdown)", target);
StartAutoLaunchCountdown(row);
}
private async Task InstallVersionAsync(VersionRowViewModel row)
{
if (row.Remote is null) return;
@@ -1065,6 +1425,24 @@ public sealed partial class MainViewModel : ObservableObject
// au format progress « ⬇ v1.4.6 : X / 14 Go • Y Mo/s » dès le premier rapport.
var zipPath = await _downloadManager.DownloadAsync(job, dlProgress, hashProgress, ct);
// Promote au cache LAN IMMÉDIATEMENT après le DL (avant l'extract local).
// Pourquoi avant : sans ça, le sidecar SHA n'existe qu'après l'install complet
// (extract + redist + deploy ~ 20-60 s), pendant lesquelles les autres PCs
// qui probe /v1/has/{version} reçoivent 404 et tombent sur OVH inutilement.
// En promotant ici, dès que notre DL OVH est fini, on devient seed LAN pour
// les autres pendant que notre propre install local continue en parallèle.
try
{
await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct);
var cachedNow = _zipCacheStore.ListCached().Select(c => c.Version).ToList();
_logger.LogInformation("LAN cache after DL (pre-install) : {Count} version(s) [{Versions}]",
cachedNow.Count, string.Join(", ", cachedNow));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ZIP cache promote failed (non-critical, LAN serving will lag until next install)");
}
// 4) Install
row.State = VersionRowState.Installing;
StatusMessage = Strings.StatusInstallingVersion(row.Version);
@@ -1101,22 +1479,31 @@ public sealed partial class MainViewModel : ObservableObject
// installer. Track via metadata pour ne pas re-runner à chaque réinstall.
// Si un installer fail (already-installed retournent souvent un exit code
// non-zero), on log mais on continue — l'install PROSERVE ne doit pas être
// bloquée par un redist mineur.
await InstallRedistsAsync(target, row.Version, ct);
// bloquée par un redist mineur. Wrappé en try/catch : si une exception
// non gérée fuit (Process.Start qui throw bizarre, UAC denied catastrophique,
// …), on ne doit SURTOUT PAS skip le PromoteAsync ci-dessous, sinon les
// peers LAN ne verraient pas la version.
try
{
await InstallRedistsAsync(target, row.Version, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
_logger.LogError(ex, "Redists install threw an unhandled exception (continuing to cache promote)");
}
// 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.
// Prune les vieilles versions selon MaxCachedVersions (le Promote a déjà
// eu lieu juste après le DL, voir bloc plus haut). On prune ici post-install
// pour que les versions encore installées soient protégées (la version qu'on
// vient d'installer compte comme "encore installée").
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)");
_logger.LogWarning(ex, "ZIP cache prune failed (non-critical)");
}
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
@@ -1288,6 +1675,25 @@ public sealed partial class MainViewModel : ObservableObject
}
}
// Cleanup explicite de l'ancien row instance AVANT le RebuildList.
// Sans ça :
// 1. `row.State` reste à `Installing` (jamais resété depuis le step 4
// ligne ~1260) → si un binding survit sur cette instance le temps
// que WPF rafraîchisse OtherVersions.Clear()+Add(), l'utilisateur
// voit brièvement (ou durablement, cf. bug rapporté) une row
// « en cours d'installation » à côté du toast "✓ installée".
// 2. `_activeRow` pointe encore sur cette OLD instance, ce qui
// maintient `HasActiveDownload` truthy le temps que `finally`
// tourne — pas grave en pratique mais pas propre.
// Le RebuildList va de toute façon créer une NOUVELLE instance
// `ForInstalled(...)` pour cette version puisque le scan disque la
// détecte maintenant. Cet edit purge l'ancienne instance avant que
// les bindings puissent s'y raccrocher.
row.State = VersionRowState.InstalledIdle;
row.ProgressPercent = 0;
row.ProgressDetail = null;
_activeRow = null;
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
ProgressDetail = null;
ProgressPercent = 0;

View File

@@ -116,6 +116,15 @@ public sealed partial class SettingsViewModel : ObservableObject
public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new();
public bool HasLanDiscoveredPeers => LanDiscoveredPeers.Count > 0;
// ----- Mode auto -----
[ObservableProperty] private bool _autoModeFeatureEnabled;
[ObservableProperty] private int _autoModeGraceSeconds;
[ObservableProperty] private string _autoModeSelectedVersion = string.Empty;
/// <summary>Si true → countdown attend que tous les health checks soient OK avant de démarrer.</summary>
[ObservableProperty] private bool _autoModeWaitForHealthChecks;
/// <summary>Args édités dans la UI. Persiste vers <c>_config.AutoMode.Args</c> au Save.</summary>
public System.Collections.ObjectModel.ObservableCollection<AutoModeArgRowViewModel> AutoModeArgs { get; } = new();
// ----- Health checks (bandeau de santé système) -----
/// <summary>
/// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne
@@ -248,6 +257,13 @@ public sealed partial class SettingsViewModel : ObservableObject
_apiAutoDeploy = config.ApiTool.AutoDeploy;
_apiMaxBackups = config.ApiTool.MaxBackups;
_autoModeFeatureEnabled = config.AutoMode.FeatureEnabled;
_autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds;
_autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty;
_autoModeWaitForHealthChecks = config.AutoMode.WaitForHealthChecks;
foreach (var a in config.AutoMode.Args ?? new List<AutoModeArg>())
AutoModeArgs.Add(new AutoModeArgRowViewModel(a.Key, a.Value, RemoveAutoModeArg));
_lanServerEnabled = config.LanCache.ServerEnabled;
_lanClientEnabled = config.LanCache.ClientEnabled;
_lanServerPort = config.LanCache.ServerPort;
@@ -413,6 +429,20 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);
_config.AutoMode.FeatureEnabled = AutoModeFeatureEnabled;
_config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60);
_config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion)
? null : AutoModeSelectedVersion.Trim();
_config.AutoMode.WaitForHealthChecks = AutoModeWaitForHealthChecks;
_config.AutoMode.Args = AutoModeArgs
.Select(r => new AutoModeArg
{
Key = (r.Key ?? string.Empty).Trim(),
Value = string.IsNullOrEmpty(r.Value) ? null : r.Value.Trim(),
})
.Where(a => !string.IsNullOrEmpty(a.Key))
.ToList();
_config.LanCache.ServerEnabled = LanServerEnabled;
_config.LanCache.ClientEnabled = LanClientEnabled;
_config.LanCache.ServerPort = LanServerPort > 0 && LanServerPort <= 65535 ? LanServerPort : 47623;
@@ -990,6 +1020,42 @@ public sealed partial class SettingsViewModel : ObservableObject
IsDeployingApi = false;
}
}
// ===================== AUTO MODE ARGS =====================
[RelayCommand]
private void AddAutoModeArg()
{
AutoModeArgs.Add(new AutoModeArgRowViewModel(string.Empty, null, RemoveAutoModeArg));
}
private void RemoveAutoModeArg(AutoModeArgRowViewModel row)
{
AutoModeArgs.Remove(row);
}
}
/// <summary>
/// Ligne éditable d'un argument CLI passé à PROSERVE en mode auto. Clé requise,
/// valeur optionnelle (un flag seul comme <c>-autoconnect</c>). Persiste vers
/// <c>_config.AutoMode.Args</c> au Save.
/// </summary>
public sealed partial class AutoModeArgRowViewModel : ObservableObject
{
[ObservableProperty] private string _key;
[ObservableProperty] private string? _value;
private readonly Action<AutoModeArgRowViewModel> _onRemove;
public AutoModeArgRowViewModel(string key, string? value, Action<AutoModeArgRowViewModel> onRemove)
{
_key = key ?? string.Empty;
_value = value;
_onRemove = onRemove;
}
[RelayCommand]
private void Remove() => _onRemove(this);
}
/// <summary>

View File

@@ -71,6 +71,44 @@ public sealed partial class VersionRowViewModel : ObservableObject
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
private bool _licenseAllowsDownload = true;
/// <summary>
/// True quand cette version est la version désignée pour l'auto-launch
/// (bouton AUTO orange/coloré). Une seule row peut être sélectionnée à la
/// fois ; le MainViewModel maintient l'unicité quand on toggle.
/// </summary>
[ObservableProperty]
private bool _isAutoModeSelected;
/// <summary>
/// True si le bouton AUTO doit être affiché sur cette row (master toggle ON
/// + version installée). Piloté par le MainViewModel via property update à
/// chaque RebuildList.
/// </summary>
[ObservableProperty]
private bool _showAutoButton;
public Action<VersionRowViewModel>? ToggleAutoModeHandler { get; set; }
[RelayCommand]
private void ToggleAutoMode()
{
// Trace fichier pour diagnostiquer "le bouton AUTO ne fait rien".
// Si on voit cette ligne mais pas la suivante côté MainViewModel,
// c'est que le handler n'est pas attaché (bug WireRowHandlers).
try
{
var dir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "logs");
System.IO.Directory.CreateDirectory(dir);
System.IO.File.AppendAllText(
System.IO.Path.Combine(dir, "auto-mode.log"),
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z VersionRowViewModel.ToggleAutoMode invoked for v{Version} (handler attached={ToggleAutoModeHandler is not null}){Environment.NewLine}");
}
catch { }
ToggleAutoModeHandler?.Invoke(this);
}
public bool HasResumableDownload => ResumableBytes > 0;
public bool LicenseAllowsInstall => LicenseAllowsDownload;

View File

@@ -0,0 +1,72 @@
<Window x:Class="PSLauncher.App.Views.AutoRelaunchDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.AutoRelaunchTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="560" Height="420"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Topmost="True"
Background="{StaticResource Brush.Bg.Window}">
<Grid Margin="36,32">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Titre dynamique : "Vérification de la santé système" pendant la
phase d'attente, "Lancement automatique" pendant le countdown. -->
<TextBlock Grid.Row="0"
x:Name="TitleText"
Text="{x:Static loc:Strings.AutoRelaunchTitle}"
FontSize="22" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="1"
x:Name="MessageText"
FontSize="14"
Foreground="{StaticResource Brush.Text.Secondary}"
TextWrapping="Wrap"
TextAlignment="Center"
Margin="0,16,0,0" />
<!-- Affiché pendant la phase santé : compteur d'indicateurs en attente
+ nom des checks non-OK. Masqué pendant le countdown. -->
<TextBlock Grid.Row="2"
x:Name="HealthPendingText"
FontSize="13"
Foreground="#FBBF24"
TextWrapping="Wrap"
TextAlignment="Center"
Margin="0,12,0,0"
Visibility="Collapsed" />
<!-- Affiché pendant le countdown. Gros chiffre orange. -->
<TextBlock Grid.Row="2"
x:Name="CountdownText"
FontSize="72"
FontWeight="Bold"
HorizontalAlignment="Center"
Foreground="{StaticResource Brush.Accent}"
Margin="0,20,0,0"
Visibility="Collapsed" />
<!-- Row 3 = espacement * pour aérer entre le contenu et le bouton -->
<Button Grid.Row="4"
Style="{StaticResource DangerButton}"
Content="{x:Static loc:Strings.AutoRelaunchCancel}"
FontSize="14"
Padding="40,12"
HorizontalAlignment="Center"
Margin="0,16,0,0"
IsCancel="True"
IsDefault="True"
Click="OnCancel" />
</Grid>
</Window>

View File

@@ -0,0 +1,162 @@
using System.Windows;
using System.Windows.Threading;
using PSLauncher.Core.Localization;
namespace PSLauncher.App.Views;
/// <summary>
/// Modal de lancement automatique de PROSERVE. Utilisé pour les 3 entry points
/// du mode auto :
/// 1. Click sur le bouton AUTO d'une row → désigne + lance
/// 2. Démarrage du launcher avec mode auto configuré → lance
/// 3. PROSERVE quitte alors qu'il était la version auto → relance
///
/// Deux phases possibles selon <see cref="AutoModeConfig.WaitForHealthChecks"/> :
/// • <b>Phase santé</b> (optionnelle) : si la config dit d'attendre que tous
/// les indicateurs de santé système soient OK, on poll le callback
/// <see cref="_healthSnapshot"/> tant qu'il retourne une liste non vide.
/// Le bouton Annuler permet de sortir et désactiver le mode auto.
/// • <b>Phase countdown</b> : décompte visuel de N secondes avant lancement
/// (configurable via GracePeriodSeconds, clampé 1-60).
///
/// DialogResult :
/// <c>true</c> = phase countdown terminée à zéro, MainViewModel doit lancer
/// <c>false</c> = l'opérateur a cliqué Annuler (à n'importe quelle phase),
/// MainViewModel doit désactiver SelectedVersion
/// </summary>
public partial class AutoRelaunchDialog : Window
{
private readonly DispatcherTimer _timer;
private readonly int _graceSeconds;
private readonly string _versionName;
/// <summary>
/// Callback fourni par MainViewModel pour interroger l'état de santé.
/// Retourne la liste des NOMS d'indicateurs qui ne sont PAS encore OK
/// (Warning, Error, ou Unknown). Liste vide ⇒ tout est OK ⇒ on peut
/// passer à la phase countdown. Null si la phase santé n'est pas
/// demandée pour ce lancement (option WaitForHealthChecks=false ou
/// aucun health check configuré).
/// </summary>
private readonly Func<IReadOnlyList<string>>? _healthSnapshot;
private enum Phase { HealthWait, Countdown }
private Phase _phase;
private int _remainingSeconds;
public AutoRelaunchDialog(string versionName, int graceSeconds,
Func<IReadOnlyList<string>>? healthSnapshot = null)
{
InitializeComponent();
_versionName = versionName;
_graceSeconds = Math.Clamp(graceSeconds, 1, 60);
_remainingSeconds = _graceSeconds;
_healthSnapshot = healthSnapshot;
// Si on a un callback santé ET qu'au premier check il y a des
// indicateurs en attente, on démarre en phase santé. Sinon on saute
// direct au countdown (cas WaitForHealthChecks=false OU tout est
// déjà OK au moment où le dialog s'ouvre).
var initialPending = _healthSnapshot?.Invoke() ?? Array.Empty<string>();
if (initialPending.Count > 0)
{
_phase = Phase.HealthWait;
EnterHealthWaitPhase(initialPending);
}
else
{
_phase = Phase.Countdown;
EnterCountdownPhase();
}
// Un seul timer pour les deux phases : poll santé toutes les 1s ou
// décrémente le countdown toutes les 1s. Simplifie le cleanup.
_timer = new DispatcherTimer(DispatcherPriority.Normal)
{
Interval = TimeSpan.FromSeconds(1),
};
_timer.Tick += OnTick;
_timer.Start();
}
private void EnterHealthWaitPhase(IReadOnlyList<string> pending)
{
TitleText.Text = Strings.AutoLaunchHealthWaitTitle;
MessageText.Text = Strings.AutoLaunchHealthWaitMessage;
HealthPendingText.Visibility = Visibility.Visible;
CountdownText.Visibility = Visibility.Collapsed;
UpdatePendingText(pending);
}
private void EnterCountdownPhase()
{
TitleText.Text = Strings.AutoRelaunchTitle;
MessageText.Text = Strings.AutoRelaunchMessage(_versionName);
HealthPendingText.Visibility = Visibility.Collapsed;
CountdownText.Visibility = Visibility.Visible;
_remainingSeconds = _graceSeconds;
UpdateCountdown();
}
private void UpdatePendingText(IReadOnlyList<string> pending)
{
// Ligne 1 : "Indicateurs en attente : N". Ligne 2 : noms séparés par ' • '.
// On limite à 4 noms affichés pour ne pas casser le layout si l'opérateur
// a configuré 15 checks. Le tooltip système couvre le détail complet.
var header = Strings.AutoLaunchHealthWaitPending(pending.Count);
var preview = pending.Count <= 4
? string.Join(" • ", pending)
: string.Join(" • ", pending.Take(4)) + " …";
HealthPendingText.Text = $"{header}\n{preview}";
}
private void OnTick(object? sender, EventArgs e)
{
if (_phase == Phase.HealthWait)
{
var pending = _healthSnapshot?.Invoke() ?? Array.Empty<string>();
if (pending.Count == 0)
{
// Transition vers countdown : tout est OK maintenant.
_phase = Phase.Countdown;
EnterCountdownPhase();
}
else
{
UpdatePendingText(pending);
}
return;
}
// Phase.Countdown
_remainingSeconds--;
if (_remainingSeconds <= 0)
{
_timer.Stop();
DialogResult = true; // lancement
Close();
}
else
{
UpdateCountdown();
}
}
private void UpdateCountdown()
{
CountdownText.Text = _remainingSeconds.ToString();
}
private void OnCancel(object sender, RoutedEventArgs e)
{
_timer.Stop();
DialogResult = false; // annulation
Close();
}
protected override void OnClosed(EventArgs e)
{
_timer.Stop();
base.OnClosed(e);
}
}

View File

@@ -126,6 +126,27 @@
<!-- Action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0">
<!-- Bouton AUTO : sélectionne cette version pour l'auto-launch
au démarrage du launcher. Fond gris quand inactif, orange
quand sélectionné. Visible seulement si FeatureEnabled. -->
<Button Content="{x:Static loc:Strings.ActionAuto}"
Padding="14,8" FontSize="13" FontWeight="Bold"
Margin="0,0,6,0"
Command="{Binding ToggleAutoModeCommand}"
Visibility="{Binding ShowAutoButton, Converter={StaticResource BoolToVisibility}}">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource SecondaryButton}">
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipInactive}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsAutoModeSelected}" Value="True">
<Setter Property="Background" Value="#F5A623" />
<Setter Property="Foreground" Value="White" />
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipActive}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button Style="{StaticResource PrimaryButton}"
Content="{x:Static loc:Strings.ActionLaunch}" Padding="22,8" FontSize="13"
Command="{Binding LaunchCommand}"
@@ -527,13 +548,34 @@
</TextBlock>
<!-- Action principale (gros bouton) -->
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Style="{StaticResource PrimaryButton}"
Content="{x:Static loc:Strings.ActionLaunchBig}"
FontSize="20" Padding="56,16"
VerticalAlignment="Center"
Command="{Binding FeaturedVersion.LaunchCommand}"
Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Orientation="Horizontal" VerticalAlignment="Center"
Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}">
<!-- Bouton AUTO featured (taille assortie au gros LANCER) -->
<Button Content="{x:Static loc:Strings.ActionAuto}"
FontSize="16" FontWeight="Bold"
Padding="20,16"
Margin="0,0,12,0"
Command="{Binding FeaturedVersion.ToggleAutoModeCommand}"
Visibility="{Binding FeaturedVersion.ShowAutoButton, Converter={StaticResource BoolToVisibility}}">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource SecondaryButton}">
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipInactive}" />
<Style.Triggers>
<DataTrigger Binding="{Binding FeaturedVersion.IsAutoModeSelected}" Value="True">
<Setter Property="Background" Value="#F5A623" />
<Setter Property="Foreground" Value="White" />
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipActive}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button Style="{StaticResource PrimaryButton}"
Content="{x:Static loc:Strings.ActionLaunchBig}"
FontSize="20" Padding="56,16"
Command="{Binding FeaturedVersion.LaunchCommand}" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Orientation="Vertical" VerticalAlignment="Center"

View File

@@ -174,11 +174,13 @@
importantes (License, Langue, About) qui restent dépliées.
Placé en BAS de la fenêtre car ce sont des trucs de power-user / support.
-->
<Expander Header="{x:Static loc:Strings.SettingsAdvanced}"
<Expander x:Name="AdvancedExpander"
Header="{x:Static loc:Strings.SettingsAdvanced}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" FontWeight="Bold"
Background="Transparent" BorderThickness="0"
Margin="0,0,0,16" IsExpanded="False">
Margin="0,0,0,16" IsExpanded="False"
Expanded="OnAdvancedExpanded">
<StackPanel Margin="0,12,0,0">
<!-- Serveur -->
@@ -648,6 +650,94 @@
</StackPanel>
</Border>
<!-- Mode auto : auto-launch + auto-relaunch d'une version désignée -->
<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.SettingsAutoMode}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<CheckBox IsChecked="{Binding AutoModeFeatureEnabled}"
Margin="0,4,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsAutoModeFeatureEnabled}" />
<CheckBox IsChecked="{Binding AutoModeWaitForHealthChecks}"
Margin="0,8,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsAutoModeWaitForHealth}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsAutoModeGrace}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding AutoModeGraceSeconds, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</Grid>
<TextBlock Margin="0,12,0,4"
Text="{Binding AutoModeSelectedVersion, StringFormat='Version désignée actuelle : v{0}', FallbackValue='Aucune version désignée'}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" FontStyle="Italic" />
<TextBlock Text="{x:Static loc:Strings.SettingsAutoModeArgs}"
FontWeight="Bold" FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,16,0,6" TextWrapping="Wrap" />
<!-- Liste éditable d'arguments. Chaque ligne : Key + Value (optionnel) + Remove. -->
<ItemsControl ItemsSource="{Binding AutoModeArgs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding Key, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas"
Tag="{x:Static loc:Strings.SettingsAutoModeArgKey}"
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgKey}" />
<TextBox Grid.Column="1"
Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas"
Margin="6,0,6,0"
Tag="{x:Static loc:Strings.SettingsAutoModeArgValue}"
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgValue}" />
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsAutoModeArgRemove}"
Command="{Binding RemoveCommand}"
Padding="10,6" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsAutoModeArgAdd}"
Command="{Binding AddAutoModeArgCommand}"
HorizontalAlignment="Left"
Margin="0,4,0,0" Padding="12,6" />
</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"

View File

@@ -1,5 +1,10 @@
using System;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Extensions.DependencyInjection;
using PSLauncher.App.ViewModels;
using PSLauncher.Core.Security;
namespace PSLauncher.App.Views;
@@ -25,4 +30,80 @@ public partial class SettingsDialog : Window
DialogResult = false;
Close();
}
/// <summary>
/// Quand l'opérateur clique pour déplier les paramètres avancés, on vérifie le
/// settings lock service. Si verrouillé, on affiche le password prompt. Si Cancel
/// ou mauvais mot de passe, on referme l'expander pour que la section reste cachée.
///
/// Trace écrite dans un fichier dédié (settings-lock.log) à chaque appel pour
/// permettre de diagnostiquer les cas "l'expander se déplie pas" sans dépendre
/// du Serilog principal (au cas où l'event ne traverserait même pas le code).
/// </summary>
private void OnAdvancedExpanded(object sender, RoutedEventArgs e)
{
TraceExpanded("OnAdvancedExpanded fired");
if (sender is not Expander exp)
{
TraceExpanded("sender is not Expander, aborting");
return;
}
// Résout le service depuis l'App DI container. Service-locator pattern toléré
// ici car SettingsDialog n'est pas injectable directement (instancié par new).
var app = (App)Application.Current;
var lockService = app.HostServices?.GetService<ISettingsLockService>();
if (lockService is null)
{
TraceExpanded("lockService is null, expander stays open");
return;
}
TraceExpanded($"lockService.HasLockConfigured={lockService.HasLockConfigured} IsLocked={lockService.IsLocked}");
if (!lockService.IsLocked)
{
TraceExpanded("not locked, expander stays open");
return;
}
TraceExpanded("locked, showing password dialog");
bool? ok = null;
try
{
var dialog = new SettingsLockDialog(lockService) { Owner = this };
ok = dialog.ShowDialog();
TraceExpanded($"dialog result: {ok}");
}
catch (Exception ex)
{
// Toute exception (XAML parse error, missing resource, etc.) doit être
// logguée et NE PAS rester silencieuse. Sans ce catch, le bug se manifeste
// par "rien ne s'ouvre, mais le log dit que ça devrait" (cas 0.28.2 avec
// Brush.Status.Danger inexistant).
TraceExpanded($"EXCEPTION creating/showing SettingsLockDialog: {ex.GetType().Name}: {ex.Message}");
// On laisse l'expander DÉPLIÉ (ok=null tombera dans le if false ci-dessous
// qui le refermerait). Plutôt : on garde déplié pour ne pas frustrer l'user
// si le lock dialog est cassé — il aura accès, et le bug doit être réglé.
return;
}
if (ok != true)
{
// Cancel ou mauvais mot de passe → on referme l'expander.
exp.IsExpanded = false;
}
}
private static void TraceExpanded(string msg)
{
try
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "logs");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "settings-lock.log");
File.AppendAllText(path,
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z {msg}{Environment.NewLine}");
}
catch { /* best effort */ }
}
}

View File

@@ -0,0 +1,64 @@
<Window x:Class="PSLauncher.App.Views.SettingsLockDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.SettingsLockedTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="520" Height="360"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Topmost="True"
ShowInTaskbar="False"
Background="{StaticResource Brush.Bg.Window}">
<Grid Margin="32,28">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
FontSize="18" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}">
<Run Text="🔒 " />
<Run Text="{x:Static loc:Strings.SettingsLockedTitle}" />
</TextBlock>
<TextBlock Grid.Row="1"
Text="{x:Static loc:Strings.SettingsLockedBody}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" TextWrapping="Wrap"
Margin="0,8,0,12" />
<PasswordBox Grid.Row="2"
x:Name="PasswordInput"
Background="#1A1A20"
Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
Padding="8" FontSize="14"
KeyDown="OnPasswordKeyDown" />
<TextBlock Grid.Row="3"
x:Name="ErrorText"
Foreground="#FCA5A5"
FontSize="11"
Margin="0,4,0,0"
Visibility="Collapsed"
Text="{x:Static loc:Strings.SettingsLockedWrongPassword}" />
<StackPanel Grid.Row="4" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.ActionCancel}" IsCancel="True"
Margin="0,0,12,0"
Click="OnCancel" />
<Button Style="{StaticResource AccentButton}"
Content="{x:Static loc:Strings.SettingsLockedUnlock}"
Padding="20,8"
IsDefault="True"
Click="OnUnlock" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,77 @@
using System.Windows;
using System.Windows.Input;
using PSLauncher.Core.Security;
namespace PSLauncher.App.Views;
/// <summary>
/// Dialog de saisie du password qui déverrouille la section Avancés des Settings.
/// Le password est hashé en SHA-256 et comparé au hash du manifest signé. L'unlock
/// est valable jusqu'à la fermeture du launcher (en-mémoire dans <see cref="ISettingsLockService"/>).
///
/// DialogResult :
/// <c>true</c> = unlock réussi
/// <c>false</c> = annulation (Cancel)
/// </summary>
public partial class SettingsLockDialog : Window
{
private readonly ISettingsLockService _lock;
public SettingsLockDialog(ISettingsLockService lockService)
{
InitializeComponent();
_lock = lockService;
Loaded += (_, _) =>
{
// Trace pour diagnostiquer un éventuel "le dialog ne s'affiche pas".
// Si on voit cette ligne dans settings-lock.log, le dialog est rendu
// → le pb est ailleurs (hors écran, derrière owner, etc.).
try
{
var dir = System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "logs");
System.IO.Directory.CreateDirectory(dir);
System.IO.File.AppendAllText(
System.IO.Path.Combine(dir, "settings-lock.log"),
$"{System.DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z SettingsLockDialog Loaded (visible={IsVisible}, left={Left}, top={Top}){System.Environment.NewLine}");
}
catch { }
Activate();
PasswordInput.Focus();
};
}
private void OnPasswordKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
TryUnlock();
e.Handled = true;
}
}
private void OnUnlock(object sender, RoutedEventArgs e) => TryUnlock();
private void TryUnlock()
{
var ok = _lock.TryUnlock(PasswordInput.Password);
if (ok)
{
DialogResult = true;
Close();
}
else
{
ErrorText.Visibility = Visibility.Visible;
PasswordInput.Clear();
PasswordInput.Focus();
}
}
private void OnCancel(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}

View File

@@ -52,9 +52,21 @@ public sealed class ZipCacheStore : IZipCacheStore
var sha = File.ReadAllText(shaPath, Encoding.ASCII).Trim();
if (sha.Length != 64) return false; // SHA-256 hex = 64 chars
var fi = new FileInfo(zipPath);
// Force la lecture des metadata maintenant — si le fichier disparaît
// ici (race avec File.Move overwrite=true du DownloadManager), on attrape
// FileNotFoundException ci-dessous silencieusement. Sans ce Refresh(),
// FileInfo.Length aurait fait l'I/O lazy et throw plus tard.
fi.Refresh();
entry = new CachedZip(version, zipPath, fi.Length, sha, fi.LastWriteTimeUtc);
return true;
}
catch (FileNotFoundException)
{
// Race connue et bénigne : un peer client probe /v1/has/{ver} pile au
// moment où DownloadManager.VerifyAndFinalizeAsync fait File.Move
// (delete-then-rename sur NTFS). Le client retentera ou tombera sur OVH.
return false;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Cache lookup failed for {Version}", version);

View File

@@ -7,6 +7,7 @@ using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using NSec.Cryptography;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Security;
using PSLauncher.Models;
namespace PSLauncher.Core.Licensing;
@@ -36,6 +37,7 @@ public sealed class LicenseService : ILicenseService
private readonly Func<string> _serverBaseUrlProvider;
private readonly IConfigStore _configStore;
private readonly LocalConfig _config;
private readonly ISettingsLockService _settingsLock;
private readonly ILogger<LicenseService> _logger;
private readonly string? _serverPublicKeyHex;
@@ -44,10 +46,12 @@ public sealed class LicenseService : ILicenseService
Func<string> serverBaseUrlProvider,
IConfigStore configStore,
LocalConfig config,
ISettingsLockService settingsLock,
ILogger<LicenseService> logger)
{
_http = http;
_serverBaseUrlProvider = serverBaseUrlProvider;
_settingsLock = settingsLock;
_configStore = configStore;
_config = config;
_logger = logger;
@@ -130,6 +134,10 @@ public sealed class LicenseService : ILicenseService
_logger.LogDebug("License response signature OK");
}
// Propage le hash du mot de passe settings lock vers le service in-memory.
// L'intégrité est garantie par la signature Ed25519 vérifiée juste au-dessus.
_settingsLock.SetPasswordHash(parsed.SettingsLockPasswordHash);
return parsed;
}
@@ -206,7 +214,7 @@ public sealed class LicenseService : ILicenseService
cachedStatus = "expired";
}
return new LicenseValidationResponse
var cached = new LicenseValidationResponse
{
Status = cachedStatus,
OwnerName = _config.License.CachedOwnerName,
@@ -219,7 +227,13 @@ public sealed class LicenseService : ILicenseService
// jamais les builds spécifiques à son channel (firefighter, police…).
Channel = _config.License.CachedChannel,
CanSeeBetas = _config.License.CachedCanSeeBetas,
SettingsLockPasswordHash = _config.License.CachedSettingsLockPasswordHash,
};
// Propagation au service in-memory : même au démarrage offline, le hash
// cache permet de gater les Settings → Avancés tant qu'on n'a pas pu
// revalider online.
_settingsLock.SetPasswordHash(cached.SettingsLockPasswordHash);
return cached;
}
public void SaveCached(string licenseKey, LicenseValidationResponse response)
@@ -236,6 +250,7 @@ public sealed class LicenseService : ILicenseService
_config.License.CachedStatus = response.Status;
_config.License.CachedChannel = response.Channel;
_config.License.CachedCanSeeBetas = response.CanSeeBetas;
_config.License.CachedSettingsLockPasswordHash = response.SettingsLockPasswordHash;
_configStore.Save(_config);
}
@@ -323,6 +338,7 @@ public sealed class LicenseService : ILicenseService
// ATTENTION : ordre CRITIQUE — Crypto::canonicalJson côté PHP ne trie pas
// les clés, il prend l'ordre du tableau associatif. Toute modif ici doit
// être miroir exact du dictionnaire PHP dans ValidateLicense.php.
// Version courante (v3, depuis v0.28) : inclut settingsLockPasswordHash.
var dict = new Dictionary<string, object?>
{
["status"] = response.Status,
@@ -333,6 +349,35 @@ public sealed class LicenseService : ILicenseService
["maxMachines"] = response.MaxMachines,
["channel"] = response.Channel, // null si default
["canSeeBetas"] = response.CanSeeBetas,
["settingsLockPasswordHash"] = response.SettingsLockPasswordHash, // null si pas de lock
["serverTime"] = FormatDateAtom(response.ServerTime),
};
var opts = new JsonSerializerOptions
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
}
/// <summary>
/// Canonical v2 (v0.26 → v0.27) : sans <c>settingsLockPasswordHash</c>.
/// Conservé pour valider les réponses cachées par les anciennes versions
/// du client/serveur. Une fois que toutes les licenses auront été re-validées
/// online avec le nouveau serveur, on pourra retirer cette méthode.
/// </summary>
private static byte[] CanonicalBytesForV2(LicenseValidationResponse response)
{
var dict = new Dictionary<string, object?>
{
["status"] = response.Status,
["licenseId"] = response.LicenseId,
["ownerName"] = response.OwnerName,
["issuedAt"] = FormatDateAtom(response.IssuedAt),
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
["maxMachines"] = response.MaxMachines,
["channel"] = response.Channel,
["canSeeBetas"] = response.CanSeeBetas,
["serverTime"] = FormatDateAtom(response.ServerTime),
};
var opts = new JsonSerializerOptions
@@ -362,14 +407,18 @@ public sealed class LicenseService : ILicenseService
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
var sig = Convert.FromBase64String(base64Signature);
// Tente d'abord le canonical actuel (avec channel + canSeeBetas).
// Si ça échoue, tente le canonical legacy (sans ces champs) — c'est
// le format des réponses signées par les serveurs OU lancées par
// les clients d'avant v0.26. Sans ce fallback, toutes les licenses
// cachées localement avant l'update à v0.26 deviendraient invalides
// et l'utilisateur serait forcé de revalider online — bloqué hors-ligne.
var payloadCurrent = CanonicalBytesFor(response);
if (alg.Verify(pk, payloadCurrent, sig)) return true;
// Chaîne de fallback canonical pour compat ascendante :
// v3 (current) : avec settingsLockPasswordHash (depuis v0.28)
// v2 : avec channel + canSeeBetas (v0.26 → v0.27)
// legacy : sans aucun de ces champs (avant v0.26)
// Sans ces fallbacks, les licenses cachées par d'anciennes versions
// deviendraient invalides et l'utilisateur serait forcé de revalider
// online — bloqué hors-ligne.
var payloadV3 = CanonicalBytesFor(response);
if (alg.Verify(pk, payloadV3, sig)) return true;
var payloadV2 = CanonicalBytesForV2(response);
if (alg.Verify(pk, payloadV2, sig)) return true;
var payloadLegacy = CanonicalBytesForLegacy(response);
return alg.Verify(pk, payloadLegacy, sig);

View File

@@ -692,6 +692,140 @@ public static class Strings
$"⚙️ Redist {done}/{total}: {filename}"
);
// ---- Mode auto (auto-launch + auto-relaunch après exit) ----
public static string ActionAuto => T("AUTO", "AUTO", "自动", "อัตโนมัติ", "تلقائي");
public static string AutoModeTooltipActive => T(
"Cette version est désignée pour le lancement automatique. Clique pour désactiver.",
"This version is set for auto-launch. Click to disable.",
"此版本设置为自动启动。点击以禁用。",
"เวอร์ชันนี้ถูกตั้งให้เปิดอัตโนมัติ คลิกเพื่อปิด",
"هذه النسخة معيّنة للتشغيل التلقائي. انقر للتعطيل."
);
public static string AutoModeTooltipInactive => T(
"Clique pour désigner cette version comme lancée automatiquement au démarrage du launcher.",
"Click to set this version as auto-launched on launcher startup.",
"点击以设置此版本在启动器启动时自动启动。",
"คลิกเพื่อตั้งเวอร์ชันนี้ให้เปิดอัตโนมัติเมื่อตัวเปิดเริ่มต้น",
"انقر لتعيين هذه النسخة للتشغيل التلقائي عند بدء المشغل."
);
// ---- Modal de lancement auto (startup, post-exit, ou activation manuelle) ----
public static string AutoRelaunchTitle => T("Lancement automatique", "Auto-launch", "自动启动", "เปิดอัตโนมัติ", "التشغيل التلقائي");
public static string AutoRelaunchMessage(string version) => T(
$"Lancement automatique de PROSERVE v{version} dans :",
$"Auto-launching PROSERVE v{version} in:",
$"PROSERVE v{version} 自动启动倒计时:",
$"กำลังเปิด PROSERVE v{version} อัตโนมัติใน:",
$"التشغيل التلقائي لـ PROSERVE v{version} خلال:"
);
public static string AutoRelaunchCancel => T(
"Annuler (désactiver le mode auto)",
"Cancel (disable auto-mode)",
"取消(禁用自动模式)",
"ยกเลิก (ปิดโหมดอัตโนมัติ)",
"إلغاء (تعطيل الوضع التلقائي)"
);
// ---- Phase 1 du modal auto : attente santé système (option WaitForHealthChecks) ----
public static string AutoLaunchHealthWaitTitle => T(
"Vérification de la santé système",
"System health check",
"系统健康检查",
"ตรวจสอบสุขภาพระบบ",
"فحص صحة النظام"
);
public static string AutoLaunchHealthWaitMessage => T(
"En attente que tous les indicateurs système passent au vert avant de lancer PROSERVE…",
"Waiting for all system indicators to turn green before launching PROSERVE…",
"等待所有系统指标变绿后再启动 PROSERVE…",
"รอให้ตัวบ่งชี้ทั้งหมดเป็นสีเขียวก่อนเปิด PROSERVE…",
"في انتظار أن تصبح جميع مؤشرات النظام خضراء قبل تشغيل PROSERVE…"
);
public static string AutoLaunchHealthWaitPending(int count) => T(
$"Indicateurs en attente : {count}",
$"Pending indicators: {count}",
$"待处理指标:{count}",
$"ตัวบ่งชี้ที่รอ: {count}",
$"المؤشرات المعلقة: {count}"
);
// ---- Garde-fou contre double-lancement de PROSERVE ----
public static string MsgAlreadyRunningTitle => T(
"PROSERVE déjà lancé",
"PROSERVE already running",
"PROSERVE 已在运行",
"PROSERVE กำลังทำงานอยู่แล้ว",
"PROSERVE قيد التشغيل بالفعل"
);
public static string MsgAlreadyRunningBody => T(
"Une instance de PROSERVE est déjà en cours d'exécution. Le launcher refuse de la dupliquer pour éviter les conflits (sessions, ports, fichiers verrouillés).\n\nFerme l'instance existante avant d'en lancer une autre.",
"A PROSERVE instance is already running. The launcher refuses to start a second one to avoid conflicts (sessions, ports, locked files).\n\nClose the existing instance before starting another.",
"PROSERVE 实例已在运行。启动器拒绝重复启动以避免冲突(会话、端口、锁定文件)。\n\n请先关闭现有实例再启动新的。",
"มี PROSERVE ทำงานอยู่แล้ว ตัวเปิดปฏิเสธที่จะเริ่มอันที่สองเพื่อหลีกเลี่ยงความขัดแย้ง (เซสชัน, พอร์ต, ไฟล์ที่ถูกล็อก)\n\nปิดอันที่มีอยู่ก่อนเริ่มอันใหม่",
"هناك نسخة من PROSERVE قيد التشغيل بالفعل. يرفض المشغل بدء نسخة ثانية لتجنب التعارضات (الجلسات، المنافذ، الملفات المقفلة).\n\nأغلق النسخة الحالية قبل بدء أخرى."
);
// ---- Section Settings → Avancés → Mode auto ----
public static string SettingsAutoMode => T("MODE AUTO", "AUTO MODE", "自动模式", "โหมดอัตโนมัติ", "الوضع التلقائي");
public static string SettingsAutoModeFeatureEnabled => T(
"Activer la fonctionnalité mode auto (affiche les boutons AUTO sur la library)",
"Enable auto mode feature (shows AUTO buttons on the library)",
"启用自动模式功能(在库中显示 AUTO 按钮)",
"เปิดใช้คุณสมบัติโหมดอัตโนมัติ (แสดงปุ่ม AUTO ในไลบรารี)",
"تمكين ميزة الوضع التلقائي (إظهار أزرار AUTO في المكتبة)"
);
public static string SettingsAutoModeGrace => T("Délai avant lancement (s)", "Grace period before launch (s)", "启动延迟(秒)", "ดีเลย์ก่อนเปิด (วินาที)", "الفترة قبل التشغيل (ث)");
public static string SettingsAutoModeWaitForHealth => T(
"Attendre que tous les indicateurs de santé système soient OK avant de lancer",
"Wait for all system health indicators to be OK before launching",
"在启动前等待所有系统健康指标为 OK",
"รอให้ตัวบ่งชี้สุขภาพระบบทั้งหมดเป็น OK ก่อนเปิด",
"انتظر حتى تكون جميع مؤشرات صحة النظام بحالة OK قبل التشغيل"
);
public static string SettingsAutoModeArgs => T(
"Arguments CLI passés à PROSERVE. Saisis juste le nom (ex. \"autoconnect\", \"login\") — le launcher ajoute automatiquement les \"-\", \"=\" et les guillemets autour des valeurs avec espaces.",
"CLI arguments passed to PROSERVE. Just type the name (e.g. \"autoconnect\", \"login\") — the launcher auto-adds \"-\", \"=\" and quotes values containing spaces.",
"传递给 PROSERVE 的 CLI 参数。只输入名称(例如 \"autoconnect\"、\"login\"),启动器自动添加 \"-\"、\"=\" 和引号。",
"อาร์กิวเมนต์ CLI ที่ส่งไป PROSERVE พิมพ์เฉพาะชื่อ (เช่น \"autoconnect\", \"login\") — ตัวเปิดจะเพิ่ม \"-\", \"=\" และเครื่องหมายคำพูดให้อัตโนมัติ",
"وسائط CLI الممررة إلى PROSERVE. اكتب الاسم فقط (مثل \"autoconnect\"، \"login\") — يضيف المشغل تلقائياً \"-\" و \"=\" وعلامات الاقتباس."
);
public static string SettingsAutoModeArgKey => T("Clé", "Key", "键", "คีย์", "المفتاح");
public static string SettingsAutoModeArgValue => T("Valeur (optionnel)", "Value (optional)", "值(可选)", "ค่า (ตัวเลือก)", "القيمة (اختياري)");
public static string SettingsAutoModeArgAdd => T("+ Ajouter un argument", "+ Add argument", "+ 添加参数", "+ เพิ่มอาร์กิวเมนต์", "+ إضافة وسيطة");
public static string SettingsAutoModeArgRemove => T("Supprimer", "Remove", "删除", "ลบ", "إزالة");
public static string SettingsAutoModeSelected(string version) => T(
$"Version désignée : v{version}",
$"Selected version: v{version}",
$"已选版本v{version}",
$"เวอร์ชันที่เลือก: v{version}",
$"النسخة المحددة: v{version}"
);
public static string SettingsAutoModeNoneSelected => T(
"Aucune version désignée (clique le bouton AUTO sur une version installée pour la désigner).",
"No version selected (click the AUTO button on an installed version to set it).",
"未选版本(点击已安装版本上的 AUTO 按钮以选择)。",
"ยังไม่ได้เลือกเวอร์ชัน (คลิกปุ่ม AUTO บนเวอร์ชันที่ติดตั้งแล้วเพื่อเลือก)",
"لم يتم اختيار نسخة (انقر على زر AUTO في نسخة مثبتة لتحديدها)."
);
// ---- Settings lock (mot de passe pour déverrouiller la section Avancés) ----
public static string SettingsLockedTitle => T("Paramètres avancés verrouillés", "Advanced settings locked", "高级设置已锁定", "การตั้งค่าขั้นสูงถูกล็อก", "الإعدادات المتقدمة مقفلة");
public static string SettingsLockedBody => T(
"Les paramètres avancés sont protégés par un mot de passe défini par l'administrateur. Saisis-le pour les déverrouiller (déverrouillage valable jusqu'à la fermeture du launcher).",
"Advanced settings are protected by a password set by the administrator. Enter it to unlock (unlock persists until launcher restart).",
"高级设置受管理员设置的密码保护。输入密码以解锁(解锁状态保持到启动器重新启动)。",
"การตั้งค่าขั้นสูงถูกป้องกันด้วยรหัสผ่านที่ผู้ดูแลกำหนด ใส่รหัสเพื่อปลดล็อก (การปลดล็อกคงอยู่จนกว่าจะรีสตาร์ทตัวเปิด)",
"الإعدادات المتقدمة محمية بكلمة مرور حددها المسؤول. أدخلها لإلغاء القفل (إلغاء القفل يستمر حتى إعادة تشغيل المشغل)."
);
public static string SettingsLockedUnlock => T("Déverrouiller", "Unlock", "解锁", "ปลดล็อก", "إلغاء القفل");
public static string SettingsLockedWrongPassword => T(
"Mot de passe incorrect.",
"Incorrect password.",
"密码错误。",
"รหัสผ่านไม่ถูกต้อง",
"كلمة المرور غير صحيحة."
);
// ---- 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 (غير متصل)");

View File

@@ -4,6 +4,7 @@ using System.Net.Sockets;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Lan;
using PSLauncher.Core.Security;
using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
@@ -20,6 +21,7 @@ public sealed class ManifestService : IManifestService
private readonly Func<string?> _channelProvider;
private readonly IManifestCache _manifestCache;
private readonly IPeerManifestFetcher _peerManifestFetcher;
private readonly ISettingsLockService _settingsLock;
private readonly ILogger<ManifestService> _logger;
public ManifestSource LastSource { get; private set; } = ManifestSource.None;
@@ -30,6 +32,7 @@ public sealed class ManifestService : IManifestService
Func<string?> channelProvider,
IManifestCache manifestCache,
IPeerManifestFetcher peerManifestFetcher,
ISettingsLockService settingsLock,
ILogger<ManifestService> logger)
{
_http = http;
@@ -37,9 +40,23 @@ public sealed class ManifestService : IManifestService
_channelProvider = channelProvider;
_manifestCache = manifestCache;
_peerManifestFetcher = peerManifestFetcher;
_settingsLock = settingsLock;
_logger = logger;
}
/// <summary>
/// No-op depuis v0.28 : le hash settings-lock vient maintenant de la
/// <c>LicenseValidationResponse</c> (per-license). Voir
/// <see cref="PSLauncher.Core.Licensing.LicenseService.ValidateAsync"/> et
/// <see cref="PSLauncher.Core.Licensing.LicenseService.GetCached"/> pour la
/// propagation effective vers <see cref="ISettingsLockService"/>.
/// </summary>
private void PropagateSettingsLock(RemoteManifest? manifest)
{
_ = manifest;
_ = _settingsLock;
}
/// <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
@@ -76,6 +93,7 @@ public sealed class ManifestService : IManifestService
?? throw new InvalidOperationException("Empty manifest");
await _manifestCache.SaveAsync(rawJson, ct).ConfigureAwait(false);
LastSource = ManifestSource.Ovh;
PropagateSettingsLock(manifest);
return manifest;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
@@ -86,6 +104,7 @@ public sealed class ManifestService : IManifestService
if (cached is not null)
{
LastSource = ManifestSource.DiskCache;
PropagateSettingsLock(cached);
return cached;
}
throw;
@@ -102,6 +121,7 @@ public sealed class ManifestService : IManifestService
// Cache aussi le peer manifest pour servir aux autres clients du LAN.
await _manifestCache.SaveAsync(peerJson, ct).ConfigureAwait(false);
LastSource = ManifestSource.Peer;
PropagateSettingsLock(manifest);
return manifest;
}
@@ -111,6 +131,7 @@ public sealed class ManifestService : IManifestService
{
_logger.LogInformation("Manifest récupéré depuis le cache disque (offline + pas de peer)");
LastSource = ManifestSource.DiskCache;
PropagateSettingsLock(localCache);
return localCache;
}

View File

@@ -20,14 +20,32 @@ public sealed class ProcessLauncher : IProcessLauncher
{
FileName = version.ExecutablePath,
WorkingDirectory = version.FolderPath,
UseShellExecute = true
UseShellExecute = true,
};
if (args is not null)
// Construction MANUELLE de la ligne de commande (via Arguments string),
// PAS via psi.ArgumentList. Pourquoi : avec UseShellExecute=true, .NET
// re-sérialise ArgumentList en interne en appliquant un quotage Win32
// auto qui peut insérer des guillemets inattendus autour des args
// contenant `=` (cf. bug rapporté : `-login=Jerome` arrivait à Unreal
// sous une forme que FParse::Value ne reconnaissait pas).
//
// En passant Arguments directement, on contrôle au caractère près
// la ligne de commande effective — c'est la même chaîne qu'aurait
// produite un .bat avec les mêmes paramètres. Le caller (typiquement
// `MainViewModel.BuildAutoModeCliArgs`) est responsable du quotage
// approprié des valeurs avec espaces.
if (args is not null && args.Length > 0)
{
foreach (var arg in args) psi.ArgumentList.Add(arg);
psi.Arguments = string.Join(" ", args);
_logger.LogInformation("Launching {Exe} with args: {Args}",
version.ExecutablePath, psi.Arguments);
}
else
{
_logger.LogInformation("Launching {Exe}", version.ExecutablePath);
}
_logger.LogInformation("Launching {Exe}", version.ExecutablePath);
return SysProcess.Start(psi)
?? throw new InvalidOperationException("Process.Start returned null");
}

View File

@@ -0,0 +1,30 @@
namespace PSLauncher.Core.Security;
/// <summary>
/// Gestion du verrouillage par mot de passe de la section Avancés des Settings.
///
/// Le hash du password vient du manifest signé (<see cref="PSLauncher.Models.RemoteManifest.SettingsLockPasswordHash"/>) :
/// administré centralement depuis le backoffice, intégrité garantie par la signature
/// Ed25519. Quand le hash est non-null/non-vide, les paramètres avancés sont gated
/// derrière un dialog de saisie de password.
///
/// L'unlock est valable pour la session courante du launcher uniquement
/// (pas persisté sur disque). Au redémarrage, l'utilisateur doit re-saisir.
/// </summary>
public interface ISettingsLockService
{
/// <summary>True s'il y a un hash défini dans le manifest courant ET qu'il n'a pas été déverrouillé cette session.</summary>
bool IsLocked { get; }
/// <summary>True s'il y a un hash défini (lock configuré). Si false, pas de lock, accès libre.</summary>
bool HasLockConfigured { get; }
/// <summary>Tente de déverrouiller avec un password. True si match (déverrouillage actif), false sinon.</summary>
bool TryUnlock(string password);
/// <summary>Force le re-verrouillage (par exemple à la fermeture du dialog Settings).</summary>
void Lock();
/// <summary>Met à jour le hash du password (appelé après chaque fetch manifest réussi).</summary>
void SetPasswordHash(string? hash);
}

View File

@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
namespace PSLauncher.Core.Security;
public sealed class SettingsLockService : ISettingsLockService
{
private string? _expectedHashHex;
private bool _unlocked;
public bool HasLockConfigured =>
!string.IsNullOrWhiteSpace(_expectedHashHex);
public bool IsLocked => HasLockConfigured && !_unlocked;
public bool TryUnlock(string password)
{
if (!HasLockConfigured)
{
_unlocked = true;
return true;
}
if (string.IsNullOrEmpty(password)) return false;
var inputHash = HashHex(password);
var match = string.Equals(inputHash, _expectedHashHex, StringComparison.OrdinalIgnoreCase);
if (match) _unlocked = true;
return match;
}
public void Lock() => _unlocked = false;
public void SetPasswordHash(string? hash)
{
_expectedHashHex = string.IsNullOrWhiteSpace(hash) ? null : hash.Trim();
// Si le hash change (admin a modifié le password backoffice), on re-verrouille.
_unlocked = false;
}
/// <summary>SHA-256 hex lowercase d'une string UTF-8. Format attendu par le backoffice.</summary>
public static string HashHex(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
var sb = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}

View File

@@ -40,6 +40,15 @@ public sealed class LicenseValidationResponse
[JsonPropertyName("canSeeBetas")]
public bool CanSeeBetas { get; set; }
/// <summary>
/// Hash SHA-256 (hex lowercase) du mot de passe qui verrouille les Settings
/// avancés du launcher. NULL = pas de verrouillage. Défini par-license dans
/// le backoffice (table <c>licenses.settings_lock_password_hash</c>) → permet
/// un mot de passe différent par client. Inclus dans la signature Ed25519.
/// </summary>
[JsonPropertyName("settingsLockPasswordHash")]
public string? SettingsLockPasswordHash { get; set; }
[JsonPropertyName("serverTime")]
public DateTime? ServerTime { get; set; }

View File

@@ -80,6 +80,13 @@ public sealed class LocalConfig
/// </summary>
public LanCacheConfig LanCache { get; set; } = new();
/// <summary>
/// Mode auto : lance automatiquement une version sélectionnée au démarrage du
/// launcher, et relance si PROSERVE quitte (avec un grace period 3-5s pendant
/// lequel l'opérateur peut annuler pour reprendre la main).
/// </summary>
public AutoModeConfig AutoMode { 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…).
@@ -308,6 +315,63 @@ public sealed class HealthCheckEntry
public int? PingTimeoutMs { get; set; } = 1000;
}
/// <summary>
/// Configuration du mode auto. <see cref="FeatureEnabled"/> est le kill-switch
/// global — quand false, les boutons AUTO ne sont pas affichés sur les rows et
/// le launcher ne fait jamais d'auto-launch. Quand true, l'opérateur peut
/// désigner UNE version via le bouton AUTO ; le launcher lance cette version
/// au démarrage et la relance si elle quitte (après un grace period).
/// </summary>
public sealed class AutoModeConfig
{
/// <summary>
/// Master toggle : si false, le mode auto est désactivé partout (boutons AUTO
/// masqués, pas d'auto-launch). Réglable dans Settings → Avancés.
/// </summary>
public bool FeatureEnabled { get; set; } = false;
/// <summary>
/// Version courante désignée pour l'auto-launch (le bouton AUTO orange sur cette
/// row). Null = aucune désignée (mode auto inactif même si FeatureEnabled=true).
/// </summary>
public string? SelectedVersion { get; set; }
/// <summary>
/// Délai en secondes affiché dans le popup modal de relance après que PROSERVE
/// ait quitté. Pendant ce délai l'opérateur peut cliquer Annuler pour reprendre
/// la main. Clamped 3-10s côté UI.
/// </summary>
public int GracePeriodSeconds { get; set; } = 5;
/// <summary>
/// Arguments CLI passés à PROSERVE_UE_*.exe. Format produit :
/// si <c>Value</c> est null/vide : <c>-{Key}</c> (flag seul)
/// sinon : <c>-{Key}={Value}</c>
/// Exemples : <c>-autoconnect</c>, <c>-login=Jerome2</c>, <c>-ipserver=10.0.4.100</c>
/// </summary>
public List<AutoModeArg> Args { get; set; } = new();
/// <summary>
/// Si true, le launcher attend que TOUS les indicateurs de santé système
/// (<see cref="LocalConfig.HealthChecks"/>) soient en <c>HealthSeverity.Ok</c>
/// avant de démarrer le countdown de lancement automatique. Utile quand
/// PROSERVE dépend d'un serveur API, d'un MySQL local, ou de SteamVR qui
/// peut mettre quelques secondes à se stabiliser au boot. Pendant l'attente,
/// l'opérateur voit un dialog "Vérification de la santé système" avec un
/// bouton Annuler ; le countdown ne démarre qu'une fois tous les indicateurs
/// au vert. Si la liste de health checks est vide, ce flag n'a aucun effet
/// (rien à attendre → countdown immédiat).
/// </summary>
public bool WaitForHealthChecks { get; set; } = false;
}
/// <summary>Une paire clé/valeur d'argument CLI pour PROSERVE. Value optionnel.</summary>
public sealed class AutoModeArg
{
public string Key { get; set; } = string.Empty;
public string? Value { get; set; }
}
public sealed class LicenseConfig
{
public string? EncryptedKey { get; set; }
@@ -338,6 +402,14 @@ public sealed class LicenseConfig
/// </summary>
public bool CachedCanSeeBetas { get; set; }
/// <summary>
/// Hash SHA-256 du password de verrouillage des Settings avancés associé à
/// cette license (NULL = pas de verrouillage). Persisté entre les lancements
/// pour que le verrouillage reste actif même hors-ligne, jusqu'à expiration
/// du cache offline (100 jours).
/// </summary>
public string? CachedSettingsLockPasswordHash { get; set; }
/// <summary>
/// Statut tel que renvoyé par le serveur lors de la dernière validation : "valid",
/// "expired", "revoked", "invalid", "machine_limit_exceeded". Persisté pour qu'une