diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss index 794bee4..f7ae52d 100644 --- a/installer/PSLauncher.iss +++ b/installer/PSLauncher.iss @@ -11,7 +11,7 @@ #define MyAppName "PROSERVE Launcher" #define MyAppShortName "PS_Launcher" -#define MyAppVersion "0.27.4" +#define MyAppVersion "0.28.10" #define MyAppPublisher "ASTERION VR" #define MyAppURL "https://asterionvr.com" #define MyAppExeName "PS_Launcher.exe" diff --git a/server/admin/launcher.php b/server/admin/launcher.php index aaf2578..540963c 100644 --- a/server/admin/launcher.php +++ b/server/admin/launcher.php @@ -76,6 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $message = "Sortie du script (scope: launcher{$forceLabel}) :\n" . implode("\n", $result['log']); if (!$result['ok']) $messageType = 'error'; } + // (Le settings lock est maintenant par-license, géré dans licenses.php — voir migration 003.) } catch (Exception $e) { $message = $e->getMessage(); $messageType = 'error'; diff --git a/server/admin/licenses.php b/server/admin/licenses.php index 4af0fad..93a234a 100644 --- a/server/admin/licenses.php +++ b/server/admin/licenses.php @@ -145,6 +145,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $db->prepare('UPDATE licenses SET max_machines = ? WHERE id = ?')->execute([$newMax, $id]); $message = "License #{$id} : limite passée à {$newMax} machine(s)."; } + elseif ($action === 'set_settings_lock_password') { + $id = (int)($_POST['id'] ?? 0); + $pwd = (string)($_POST['settings_lock_password'] ?? ''); + if ($id <= 0) { + throw new Exception('License id invalide.'); + } + if ($pwd === '') { + // Vide = retire le verrouillage pour cette license + $db->prepare('UPDATE licenses SET settings_lock_password_hash = NULL WHERE id = ?')->execute([$id]); + $message = "License #{$id} : verrouillage des paramètres avancés retiré."; + } else { + if (strlen($pwd) < 4) { + throw new Exception('Mot de passe trop court (minimum 4 caractères).'); + } + // SHA-256 hex lowercase, format compatible avec SettingsLockService côté launcher. + $hash = hash('sha256', $pwd); + $db->prepare('UPDATE licenses SET settings_lock_password_hash = ? WHERE id = ?')->execute([$hash, $id]); + $message = "License #{$id} : mot de passe des paramètres avancés mis à jour."; + } + } elseif ($action === 'reset_machines') { $id = (int)($_POST['id'] ?? 0); $db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]); @@ -363,6 +383,24 @@ Layout::header('Licenses', 'licenses'); + +
+ + + +
+ + + + + +
+
0): ?>
diff --git a/server/api/routes/ValidateLicense.php b/server/api/routes/ValidateLicense.php index 6836e14..aba9271 100644 --- a/server/api/routes/ValidateLicense.php +++ b/server/api/routes/ValidateLicense.php @@ -30,7 +30,7 @@ final class ValidateLicense $stmt = $db->prepare( 'SELECT id, license_key, owner_name, issued_at, download_entitlement_until, - max_machines, channel, can_see_betas, revoked_at + max_machines, channel, can_see_betas, settings_lock_password_hash, revoked_at FROM licenses WHERE license_key = ? LIMIT 1' ); $stmt->execute([$licenseKey]); @@ -92,6 +92,12 @@ final class ValidateLicense ? (string)$lic['channel'] : null; $canSeeBetas = (bool)($lic['can_see_betas'] ?? 0); + // SettingsLockPasswordHash : présent depuis migration 003, NULL = pas de + // verrouillage. On renvoie tel quel (null ou hex 64). Le launcher applique + // côté ISettingsLockService.SetPasswordHash après validation signée. + $settingsLockHash = isset($lic['settings_lock_password_hash']) && $lic['settings_lock_password_hash'] !== '' + ? (string)$lic['settings_lock_password_hash'] + : null; $payload = [ 'status' => $expired ? 'expired' : 'valid', @@ -102,6 +108,7 @@ final class ValidateLicense 'maxMachines' => (int)$lic['max_machines'], 'channel' => $channel, 'canSeeBetas' => $canSeeBetas, + 'settingsLockPasswordHash' => $settingsLockHash, 'serverTime' => $serverTime, ]; diff --git a/server/migrations/002_channel_betas.sql b/server/migrations/002_channel_betas.sql index 4d826db..bf3df14 100644 --- a/server/migrations/002_channel_betas.sql +++ b/server/migrations/002_channel_betas.sql @@ -5,45 +5,14 @@ -- -- À jouer après 001_init.sql. -- --- IDEMPOTENCE : OVH mutualisé peut tourner sur MariaDB pré-10.0.2 où --- ALTER TABLE ... IF NOT EXISTS n'existe pas. On contourne avec une PROCEDURE --- temporaire qui consulte INFORMATION_SCHEMA avant chaque ALTER. Comme ça la --- migration peut être re-jouée sans erreur sur n'importe quelle version --- MariaDB / MySQL 5.5+. +-- IDEMPOTENCE : les ALTER ci-dessous throw une exception sur OVH si la colonne / +-- l'index existe déjà. migrate.php attrape ces erreurs spécifiques ("Duplicate +-- column", "Duplicate key name") et continue. Tu peux rejouer le script sans +-- risque sur n'importe quelle DB (vide, partiellement migrée, complètement). -DROP PROCEDURE IF EXISTS ps_launcher_migrate_002; - -DELIMITER $$ -CREATE PROCEDURE ps_launcher_migrate_002() -BEGIN - -- channel - IF NOT EXISTS ( - SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'licenses' AND COLUMN_NAME = 'channel' - ) THEN - ALTER TABLE licenses ADD COLUMN channel VARCHAR(64) NULL AFTER max_machines; - END IF; - - -- can_see_betas - IF NOT EXISTS ( - SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'licenses' AND COLUMN_NAME = 'can_see_betas' - ) THEN - ALTER TABLE licenses ADD COLUMN can_see_betas TINYINT(1) NOT NULL DEFAULT 0 AFTER channel; - END IF; - - -- index sur channel (une simple recherche bénéficie peu mais c'est pas cher) - IF NOT EXISTS ( - SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'licenses' AND INDEX_NAME = 'idx_channel' - ) THEN - ALTER TABLE licenses ADD INDEX idx_channel (channel); - END IF; -END $$ -DELIMITER ; - -CALL ps_launcher_migrate_002(); -DROP PROCEDURE ps_launcher_migrate_002; +ALTER TABLE licenses ADD COLUMN channel VARCHAR(64) NULL AFTER max_machines; +ALTER TABLE licenses ADD COLUMN can_see_betas TINYINT(1) NOT NULL DEFAULT 0 AFTER channel; +ALTER TABLE licenses ADD INDEX idx_channel (channel); -- Note : pas de migration de data. Les licenses existantes gardent -- channel = NULL (= manifest default) et can_see_betas = 0 (= pas d'accès aux betas). diff --git a/server/migrations/003_settings_lock.sql b/server/migrations/003_settings_lock.sql new file mode 100644 index 0000000..23cc5f6 --- /dev/null +++ b/server/migrations/003_settings_lock.sql @@ -0,0 +1,15 @@ +-- PS_Launcher schema v3 +-- Ajoute settings_lock_password_hash sur la table licenses : permet à l'admin +-- de définir un mot de passe par-license qui verrouille la section "Avancés" +-- des Settings côté launcher. Le hash SHA-256 (hex lowercase) est renvoyé au +-- launcher dans la réponse signée de /license/validate, donc l'intégrité +-- bénéficie de la signature Ed25519 existante. +-- +-- À jouer après 001_init.sql et 002_channel_betas.sql. +-- migrate.php attrape "Duplicate column" comme idempotent. + +ALTER TABLE licenses ADD COLUMN settings_lock_password_hash CHAR(64) NULL AFTER can_see_betas; + +-- Note : les licenses existantes ont settings_lock_password_hash = NULL → pas +-- de verrouillage côté launcher → comportement identique à avant. L'admin +-- attribue manuellement via le bouton 🔒 Lock dans licenses.php. diff --git a/server/tools/migrate.php b/server/tools/migrate.php index 11f65ee..c115f3b 100644 --- a/server/tools/migrate.php +++ b/server/tools/migrate.php @@ -76,7 +76,20 @@ foreach ($files as $f) { $first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60); out(" ✔ " . $first40 . "…"); } catch (Exception $e) { - out(" ✘ ERREUR : " . $e->getMessage()); + $msg = $e->getMessage(); + // Tolérance idempotence : on n'aboie pas sur les ALTER qui re-touchent + // une colonne / un index existant. Permet de rejouer migrate.php sans + // risque sur une DB déjà partiellement / complètement migrée. + $idempotent = str_contains($msg, 'Duplicate column') + || str_contains($msg, 'Duplicate key name') + || str_contains($msg, "Can't DROP") // ALTER DROP COLUMN inexistante + || str_contains($msg, 'check that column/key exists'); + if ($idempotent) { + $first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60); + out(" ↺ (déjà appliqué) " . $first40 . "…"); + continue; + } + out(" ✘ ERREUR : " . $msg); out(" Sur : " . substr($stmt, 0, 80) . "…"); exit(3); } diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index bbcd760..7105cca 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -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; + + /// + /// IServiceProvider du host DI. Exposé pour les rares cas où une vue est + /// instanciée par new hors du container (ex: SettingsDialog) et a + /// besoin de résoudre un service. + /// + 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(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => new ManifestService( sp.GetRequiredService(), @@ -186,6 +195,7 @@ public partial class App : Application () => sp.GetRequiredService().GetCached()?.Channel, sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService>())); services.AddSingleton(); @@ -198,6 +208,7 @@ public partial class App : Application () => sp.GetRequiredService().ServerBaseUrl, sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService>())); services.AddSingleton(); diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index 734c4ad..4d65a25 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -18,9 +18,9 @@ PROSERVE Launcher © 2026 ASTERION VR — All rights reserved PSLauncher.App - 0.27.4 - 0.27.4.0 - 0.27.4.0 + 0.28.10 + 0.28.10.0 + 0.28.10.0 true diff --git a/src/PSLauncher.App/ViewModels/MainViewModel.cs b/src/PSLauncher.App/ViewModels/MainViewModel.cs index 1ae4355..4b4342f 100644 --- a/src/PSLauncher.App/ViewModels/MainViewModel.cs +++ b/src/PSLauncher.App/ViewModels/MainViewModel.cs @@ -80,6 +80,17 @@ public sealed partial class MainViewModel : ObservableObject /// private Task? _activeInstallTask; + /// + /// Référence au process PROSERVE lancé par le launcher (la PLUS RÉCENTE). + /// Sert de garde-fou contre les double-lancements : avant chaque + /// , 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 à . + /// + private System.Diagnostics.Process? _runningProserve; + public ObservableCollection OtherVersions { get; } = new(); /// @@ -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); } /// @@ -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); + } + + /// + /// 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). + /// + 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); + } } /// @@ -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); } } + /// + /// True quand un lancement auto-mode est en cours de déclenchement (countdown + /// en cours OU appel 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 . + /// + private bool _isAutoLaunchInFlight; + + /// + /// True si une instance PROSERVE tourne déjà — soit lancée par nous + /// ( encore vivant), soit lancée hors launcher + /// et détectée via . Le paramètre + /// est rempli avec le motif de blocage pour les logs. + /// + 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; + } + + /// + /// Convertit la liste 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 autoconnect (sans tiret) et login / + /// Jerome. Le launcher se charge d'ajouter les -, = et + /// le quotage des valeurs avec espaces. + /// + /// Règles de formatage produites : + /// • Value vide → -{key} (flag : FParse::Param) + /// • Value sans espace → -{key}={value} (FParse::Value classique) + /// • Value avec espace → -{key}="value spaced" (quotage Win32) + /// + /// Défensif : on strip un éventuel - en tête de Key et un = en + /// tête de Value au cas où l'opérateur les a saisis dans l'UI ("au cas où" = + /// retour utilisateur : certains tapent -login ou =Jerome par + /// réflexe et obtenaient --login ou -login==Jerome à l'exécution). + /// Les clés vides sont skippées silencieusement. + /// + private static string[] BuildAutoModeCliArgs(IEnumerable args) + { + var list = new List(); + 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(); + } + + /// + /// 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 → avec args CLI mode auto + /// - Annuler → désactive le mode auto (SelectedVersion=null, bouton AUTO + /// repasse en gris sur la row CURRENT, persisté dans config.json) + /// + 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>? 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; + } + } + + /// + /// 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(). + /// + 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; diff --git a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs index e262dfa..9227895 100644 --- a/src/PSLauncher.App/ViewModels/SettingsViewModel.cs +++ b/src/PSLauncher.App/ViewModels/SettingsViewModel.cs @@ -116,6 +116,15 @@ public sealed partial class SettingsViewModel : ObservableObject public System.Collections.ObjectModel.ObservableCollection 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; + /// Si true → countdown attend que tous les health checks soient OK avant de démarrer. + [ObservableProperty] private bool _autoModeWaitForHealthChecks; + /// Args édités dans la UI. Persiste vers _config.AutoMode.Args au Save. + public System.Collections.ObjectModel.ObservableCollection AutoModeArgs { get; } = new(); + // ----- Health checks (bandeau de santé système) ----- /// /// 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()) + 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); + } +} + +/// +/// Ligne éditable d'un argument CLI passé à PROSERVE en mode auto. Clé requise, +/// valeur optionnelle (un flag seul comme -autoconnect). Persiste vers +/// _config.AutoMode.Args au Save. +/// +public sealed partial class AutoModeArgRowViewModel : ObservableObject +{ + [ObservableProperty] private string _key; + [ObservableProperty] private string? _value; + + private readonly Action _onRemove; + + public AutoModeArgRowViewModel(string key, string? value, Action onRemove) + { + _key = key ?? string.Empty; + _value = value; + _onRemove = onRemove; + } + + [RelayCommand] + private void Remove() => _onRemove(this); } /// diff --git a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs index f71697b..d212402 100644 --- a/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs +++ b/src/PSLauncher.App/ViewModels/VersionRowViewModel.cs @@ -71,6 +71,44 @@ public sealed partial class VersionRowViewModel : ObservableObject [NotifyCanExecuteChangedFor(nameof(InstallCommand))] private bool _licenseAllowsDownload = true; + /// + /// 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. + /// + [ObservableProperty] + private bool _isAutoModeSelected; + + /// + /// 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. + /// + [ObservableProperty] + private bool _showAutoButton; + + public Action? 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; diff --git a/src/PSLauncher.App/Views/AutoRelaunchDialog.xaml b/src/PSLauncher.App/Views/AutoRelaunchDialog.xaml new file mode 100644 index 0000000..9e3fcf4 --- /dev/null +++ b/src/PSLauncher.App/Views/AutoRelaunchDialog.xaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 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(); + 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 */ } + } } diff --git a/src/PSLauncher.App/Views/SettingsLockDialog.xaml b/src/PSLauncher.App/Views/SettingsLockDialog.xaml new file mode 100644 index 0000000..2d764bc --- /dev/null +++ b/src/PSLauncher.App/Views/SettingsLockDialog.xaml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + +