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:
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
72
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml
Normal file
72
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml
Normal 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>
|
||||
162
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml.cs
Normal file
162
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
64
src/PSLauncher.App/Views/SettingsLockDialog.xaml
Normal file
64
src/PSLauncher.App/Views/SettingsLockDialog.xaml
Normal 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>
|
||||
77
src/PSLauncher.App/Views/SettingsLockDialog.xaml.cs
Normal file
77
src/PSLauncher.App/Views/SettingsLockDialog.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (غير متصل)");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
30
src/PSLauncher.Core/Security/ISettingsLockService.cs
Normal file
30
src/PSLauncher.Core/Security/ISettingsLockService.cs
Normal 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);
|
||||
}
|
||||
47
src/PSLauncher.Core/Security/SettingsLockService.cs
Normal file
47
src/PSLauncher.Core/Security/SettingsLockService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user