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

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

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

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

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

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

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

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

View File

@@ -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"

View File

@@ -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';

View File

@@ -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');
<button class="btn btn-primary" type="submit">OK</button>
</form>
</details>
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary"
title="Mot de passe qui verrouille la section Settings → Avancés du launcher pour cette license. Laisser vide pour retirer le verrouillage.">
<?= $hasLock ? '🔒 Lock' : 'Lock' ?>
</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_settings_lock_password">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="password" name="settings_lock_password"
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe (≥4)' ?>"
minlength="0" maxlength="100"
style="width: 200px;"
autocomplete="new-password">
<button class="btn btn-primary" type="submit">OK</button>
</form>
</details>
<?php if ($l['machines_count'] > 0): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')">
<?= Layout::csrfField() ?>

View File

@@ -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,
];

View File

@@ -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).

View File

@@ -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.

View File

@@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,355 @@
-- =============================================================================
-- 0001_Init.sql — schéma initial PROSERVE (proserveapi).
--
-- Idempotent : tout est en IF NOT EXISTS / INSERT IGNORE / ADD CONSTRAINT IF
-- NOT EXISTS, donc rejouable sur une DB déjà peuplée sans générer d'erreur.
-- Cible MariaDB 10.0.2+ (XAMPP standard depuis longtemps).
--
-- Note : pas de START TRANSACTION / COMMIT à l'intérieur du fichier — le
-- launcher (PSLauncher.Core.Migrations.DatabaseMigrationService) wrappe déjà
-- chaque .sql dans sa propre transaction pour pouvoir rollback proprement.
--
-- Source d'origine : dump phpMyAdmin 5.2.1 / MariaDB 10.4.32 (5 fév 2026).
-- =============================================================================
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS `proserveapi`
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE `proserveapi`;
-- =============================================================================
-- TABLES
-- =============================================================================
CREATE TABLE IF NOT EXISTS `participates` (
`userId` int(11) NOT NULL COMMENT 'user id',
`sessionId` int(11) NOT NULL COMMENT 'session id',
`score` int(11) NOT NULL DEFAULT 0 COMMENT 'user score in the session',
`firePrecision` float NOT NULL DEFAULT 0 COMMENT 'user precision during the session',
`reactionTime` float NOT NULL DEFAULT 0 COMMENT 'average reaction time of the user in this session',
`nbEnemyHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of enemies hit',
`nbCivilsHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of civils hit',
`damageTaken` int(11) NOT NULL DEFAULT 0 COMMENT 'nb hits received',
`endStatus` int(1) NOT NULL DEFAULT 1 COMMENT 'user status at the end of the session (0=>dead, 1=>alive, add 2 for injured ?)',
`avatar` varchar(255) NOT NULL COMMENT 'avatar of user for this session',
`weapon` varchar(255) NOT NULL COMMENT 'weapon of user for this session',
`role` int(11) NOT NULL DEFAULT 0 COMMENT 'user role in the session',
`results` text NOT NULL DEFAULT '\'\'' COMMENT 'user''s results (objectives) in session (JSON format)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `reacteventmodes` (
`id` int(11) NOT NULL COMMENT 'id',
`displayName` varchar(255) NOT NULL COMMENT 'name',
`description` varchar(255) NOT NULL COMMENT 'description'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `reactevents` (
`id` int(11) NOT NULL COMMENT 'event id',
`srcEventSessionId` int(11) NOT NULL COMMENT 'session id',
`srcEventIndex` int(11) NOT NULL COMMENT 'id of the triggering event that initiated this reaction event',
`reactType` int(11) NOT NULL COMMENT 'react event type',
`reactMode` int(11) NOT NULL DEFAULT 0 COMMENT 'react event mode (penetration, ricochet, termination)',
`hitUserId` int(11) NOT NULL DEFAULT 0 COMMENT 'id of the user that has been hit (0 if enemy/civil/env/game)',
`hitTargetName` varchar(255) NOT NULL COMMENT 'target hit by the event',
`hitBoneName` varchar(255) NOT NULL COMMENT 'bone hit on the target',
`damage` float NOT NULL DEFAULT 0 COMMENT 'damage points for the event',
`targetKilled` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'did target got killed by the event ?',
`objectHitLocationX` float NOT NULL DEFAULT 0 COMMENT 'when hitting a target (object) record X position on texture',
`objectHitLocationY` float NOT NULL DEFAULT 0 COMMENT 'when hitting a target (object) record Y position on texture',
`objectHitTagLocation` varchar(255) NOT NULL COMMENT 'tag on the hit object',
`hitPrecision` float NOT NULL DEFAULT 1 COMMENT 'precision of the hit',
`distance` float NOT NULL DEFAULT 0 COMMENT 'distance from source to target hit',
`reactTime` float NOT NULL DEFAULT 0 COMMENT 'reaction time (for challenges)',
`timeStamp` float NOT NULL DEFAULT 0 COMMENT 'timeStamp react shot was registered'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `reacteventtypes` (
`id` int(11) NOT NULL COMMENT 'type id',
`displayName` varchar(255) NOT NULL COMMENT 'name',
`description` varchar(255) NOT NULL COMMENT 'description'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `sessions` (
`id` int(11) NOT NULL COMMENT 'session id',
`sessionType` int(11) NOT NULL COMMENT 'session type',
`sessionName` varchar(255) NOT NULL COMMENT 'session name',
`sessionDate` datetime NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the session',
`mapName` varchar(255) NOT NULL COMMENT 'map of the session',
`scenarioName` varchar(255) NOT NULL COMMENT 'scenario name that was played',
`success` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'was the session successful (scenario objective reached) ?',
`timeToFinish` float NOT NULL DEFAULT 0 COMMENT 'session duration',
`score` int(11) NOT NULL DEFAULT 0 COMMENT 'user score',
`nbEnemyHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of enemies hit',
`nbCivilsHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of civils saved',
`damageTaken` float NOT NULL DEFAULT 0 COMMENT 'total damage taken',
`replayFileName` varchar(255) NOT NULL COMMENT 'filename of the session replay'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `sessiontypes` (
`id` int(11) NOT NULL COMMENT 'session type id',
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the session type',
`description` varchar(255) NOT NULL COMMENT 'description of the session type'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `triggerevents` (
`sessionId` int(11) NOT NULL COMMENT 'id of the user session during which the event was triggered',
`indexCount` int(11) NOT NULL COMMENT 'index of event in session',
`srcUserId` int(11) NOT NULL COMMENT 'id of the user that triggered the shot',
`type` int(11) NOT NULL COMMENT 'type of event',
`successful` tinyint(1) NOT NULL COMMENT 'was the shot (or triggered event) successful (hit a target) ?',
`timeStamp` float NOT NULL COMMENT 'time the event occurred at'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `triggereventtypes` (
`id` int(11) NOT NULL COMMENT 'event type id',
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the event type',
`description` varchar(255) NOT NULL COMMENT 'description of the event type'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `userroles` (
`id` int(11) NOT NULL COMMENT 'session type id',
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the session type',
`description` varchar(255) NOT NULL COMMENT 'description of the session type'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `users` (
`id` int(11) NOT NULL COMMENT 'user id',
`username` varchar(255) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL COMMENT 'username',
`password` varchar(255) NOT NULL COMMENT 'password',
`firstName` varchar(255) NOT NULL COMMENT 'first name',
`lastName` varchar(255) NOT NULL COMMENT 'last name',
`created` timestamp NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the creation of the user account',
`leftHanded` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'is user left handed?',
`maleGender` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'is user a man (true) or a woman?',
`charSkinAssetName` varchar(255) NOT NULL COMMENT 'name of the asset for the character skin',
`weaponAssetName` varchar(255) NOT NULL COMMENT 'name of the asset for the weapon',
`lastConnection` datetime NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the last connection',
`avgPrecision` float NOT NULL COMMENT 'average precision when firing',
`avgReaction` float NOT NULL COMMENT 'average reaction time',
`avgFault` float NOT NULL COMMENT 'average error rate',
`avgRapidity` float NOT NULL COMMENT 'average rapidity time',
`size` int(6) NOT NULL DEFAULT 175
) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
CREATE TABLE IF NOT EXISTS `userstatus` (
`id` int(11) NOT NULL COMMENT 'status id',
`displayName` varchar(255) NOT NULL COMMENT 'display name for status (alive, dead)',
`description` varchar(255) NOT NULL COMMENT 'description'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- =============================================================================
-- DONNÉES DE RÉFÉRENCE (INSERT IGNORE = skip silencieux si PK déjà présente)
-- =============================================================================
INSERT IGNORE INTO `reacteventmodes` (`id`, `displayName`, `description`) VALUES
(0, 'Penetration', ''),
(1, 'Ricochet', ''),
(2, 'Termination', '');
INSERT IGNORE INTO `reacteventtypes` (`id`, `displayName`, `description`) VALUES
(0, 'EnemyHit', 'An enemy has been hit'),
(1, 'CivilianHit', 'A civilian has been hit'),
(2, 'PoliceHit', 'Another player has been hit by himself'),
(3, 'ObjectHit', 'A target (object) has been hit'),
(4, 'PaperTargetHit', ''),
(5, 'TargetHit', ''),
(6, 'DeadBodyHit', 'An already-dead body has been hit');
INSERT IGNORE INTO `sessiontypes` (`id`, `displayName`, `description`) VALUES
(0, 'FireRange', ''),
(1, 'Challenge', ''),
(2, 'Protect', ''),
(3, 'De-Escalation', ''),
(4, 'Terrorism', ''),
(5, 'FireExtinction', ''),
(6, 'Rescue', ''),
(7, 'LongRange', '');
INSERT IGNORE INTO `triggereventtypes` (`id`, `displayName`, `description`) VALUES
(0, 'Fire', 'Player has fired');
INSERT IGNORE INTO `userroles` (`id`, `displayName`, `description`) VALUES
(0, 'Police', 'Player is Police/SWAT'),
(1, 'Enemy', 'Player is an Enemy'),
(2, 'Civil', 'Player is a Civilian'),
(3, 'IA', 'Role for IA and NPC');
INSERT IGNORE INTO `userstatus` (`id`, `displayName`, `description`) VALUES
(0, 'Alive', 'avatar is alive'),
(1, 'Lightly Injured', 'avatar has been slightly injured'),
(2, 'Seriously Injured', 'avatar has been seriously injured'),
(3, 'Dead', 'avatar is dead');
-- Comptes utilisateurs initiaux (system + 2 démos). INSERT IGNORE évite
-- d'écraser ce que le client aurait déjà créé si la table users contient
-- des id=0/1/2 différents.
INSERT IGNORE INTO `users` (`id`, `username`, `password`, `firstName`, `lastName`, `created`, `leftHanded`, `maleGender`, `charSkinAssetName`, `weaponAssetName`, `lastConnection`, `avgPrecision`, `avgReaction`, `avgFault`, `avgRapidity`, `size`) VALUES
(0, 'game', '', '', '', '2023-01-24 10:52:35', 0, 0, '', '', '2023-01-24 11:52:35', 0, 0, 0, 0, 0),
(1, 'Jerome', 'Q29udHJvbDM1', 'Jerome', '', '2026-02-05 18:06:06', 0, 1, 'PS_PAWN_VR_V2_POLICE_ARABIC_C', 'Weapon_G17_C', '2026-02-05 12:31:59', 0, 0, 0, 0, 171),
(2, 'Jerome2', 'Q29udHJvbDM1', 'Jerome2', '', '2026-02-05 18:06:24', 0, 1, 'PS_PAWN_VR_V2_POLICE_BLACK_C', 'Weapon_G17_C', '2026-02-05 12:32:23', 0, 0, 0, 0, 175);
-- =============================================================================
-- INDEX & CONTRAINTES (toutes en IF NOT EXISTS pour idempotence)
-- =============================================================================
-- participates : PK composite (userId, sessionId) + FKs
ALTER TABLE `participates`
ADD PRIMARY KEY IF NOT EXISTS (`userId`,`sessionId`),
ADD KEY IF NOT EXISTS `FK_Participates_SessionId` (`sessionId`),
ADD KEY IF NOT EXISTS `FK_Participates_UserStatus` (`endStatus`),
ADD KEY IF NOT EXISTS `FK_Participates_UserRole` (`role`);
ALTER TABLE `reacteventmodes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `reactevents`
ADD PRIMARY KEY IF NOT EXISTS (`id`),
ADD KEY IF NOT EXISTS `FK_React_TypeId` (`reactType`),
ADD KEY IF NOT EXISTS `FK_React_HitUserId` (`hitUserId`),
ADD KEY IF NOT EXISTS `FK_React_EventIndex` (`srcEventIndex`),
ADD KEY IF NOT EXISTS `FK_React_SessionId` (`srcEventSessionId`),
ADD KEY IF NOT EXISTS `FK_React_Mode` (`reactMode`);
ALTER TABLE `reacteventtypes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `sessions`
ADD PRIMARY KEY IF NOT EXISTS (`id`),
ADD KEY IF NOT EXISTS `FK_sessionType` (`sessionType`) USING BTREE;
ALTER TABLE `sessiontypes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `triggerevents`
ADD PRIMARY KEY IF NOT EXISTS (`indexCount`,`sessionId`),
ADD KEY IF NOT EXISTS `FK_TriggerEvent_SrcUserId` (`srcUserId`),
ADD KEY IF NOT EXISTS `FK_TriggerEvent_Type` (`type`),
ADD KEY IF NOT EXISTS `FK_TriggerEvent_UserId` (`sessionId`);
ALTER TABLE `triggereventtypes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `userroles`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `users`
ADD PRIMARY KEY IF NOT EXISTS (`id`),
ADD UNIQUE KEY IF NOT EXISTS `username` (`username`);
ALTER TABLE `userstatus`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
-- =============================================================================
-- AUTO_INCREMENT (idempotent : MariaDB ignore la valeur si MAX(id) la dépasse)
-- =============================================================================
ALTER TABLE `reactevents`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'event id';
ALTER TABLE `sessions`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'session id';
ALTER TABLE `users`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'user id';
-- =============================================================================
-- FOREIGN KEYS
-- Syntaxe : ADD FOREIGN KEY IF NOT EXISTS `name` (...) REFERENCES ...
-- (NOTE : en MariaDB, le `CONSTRAINT name FOREIGN KEY` n'accepte PAS de clause
-- `IF NOT EXISTS` ; en revanche `FOREIGN KEY IF NOT EXISTS name` est supporté
-- depuis 10.0.2 et est strictement équivalent côté résultat.)
-- =============================================================================
ALTER TABLE `participates`
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_SessionId` (`sessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserId` (`userId`) REFERENCES `users` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserRole` (`role`) REFERENCES `userroles` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserStatus` (`endStatus`) REFERENCES `userstatus` (`id`);
ALTER TABLE `reactevents`
ADD FOREIGN KEY IF NOT EXISTS `FK_React_HitUserId` (`hitUserId`) REFERENCES `users` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_React_Mode` (`reactMode`) REFERENCES `reacteventmodes` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_React_SessionId` (`srcEventSessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_React_TypeId` (`reactType`) REFERENCES `reacteventtypes` (`id`);
ALTER TABLE `sessions`
ADD FOREIGN KEY IF NOT EXISTS `FK_SessionType` (`sessionType`) REFERENCES `sessiontypes` (`id`);
ALTER TABLE `triggerevents`
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_SrcUserId` (`srcUserId`) REFERENCES `users` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_Type` (`type`) REFERENCES `triggereventtypes` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_UserId` (`sessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE;
-- =============================================================================
-- VUE sessiondebriefs (CREATE OR REPLACE = idempotent par définition)
-- =============================================================================
CREATE OR REPLACE
ALGORITHM=UNDEFINED
SQL SECURITY DEFINER
VIEW `sessiondebriefs` AS
SELECT
`s`.`id` AS `SessionId`,
`s`.`sessionType` AS `SessionTypeId`,
`st`.`displayName` AS `SessionType`,
`s`.`sessionName` AS `SessionName`,
`s`.`sessionDate` AS `SessionDate`,
`s`.`mapName` AS `MapName`,
`s`.`scenarioName` AS `ScenarioName`,
`s`.`success` AS `SessionSuccessful`,
`s`.`timeToFinish` AS `SessionDuration`,
coalesce(`t`.`type`, -1) AS `TriggerTypeId`,
coalesce(`tt`.`displayName`, '') AS `TriggerType`,
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)) AS `ShooterId`,
`us`.`username` AS `ShooterName`,
(SELECT ifnull(`ps`.`role`, 3)) AS `ShooterRoleId`,
(SELECT `r`.`displayName` FROM `userroles` `r` WHERE `r`.`id` = `ShooterRoleId`) AS `ShooterRole`,
coalesce(`t`.`indexCount`, -1) AS `ShotIndex`,
coalesce(`r`.`id`, -1) AS `ReactId`,
coalesce(`r`.`reactMode`, 2) AS `ReactModeId`,
coalesce(`rm`.`displayName`, '') AS `ReactMode`,
coalesce(`r`.`reactType`, -1) AS `ReactTypeId`,
coalesce(`rt`.`displayName`, '') AS `ReactType`,
coalesce(`r`.`hitUserId`, -1) AS `TargetUserId`,
coalesce(`uh`.`username`, '') AS `TargetUserName`,
(SELECT ifnull(`ph`.`role`, 3)) AS `TargetRoleId`,
(SELECT `r`.`displayName` FROM `userroles` `r` WHERE `r`.`id` = `TargetRoleId`) AS `TargetRole`,
coalesce(`r`.`hitTargetName`, '') AS `TargetName`,
coalesce(`r`.`hitBoneName`, '') AS `TargetBoneName`,
coalesce(`r`.`targetKilled`, 0) AS `TargetKilled`,
coalesce(`r`.`objectHitLocationX`, 0) AS `HitLocationX`,
coalesce(`r`.`objectHitLocationY`, 0) AS `HitLocationY`,
coalesce(`r`.`objectHitTagLocation`, '') AS `HitLocationTag`,
coalesce(`r`.`hitPrecision`, 0) AS `HitPrecision`,
coalesce(`r`.`distance`, 0) AS `HitTargetDistance`,
coalesce(`r`.`reactTime`, 0) AS `ReactionTime`,
coalesce(`r`.`timeStamp`, 0) AS `TimeStamp`,
count(distinct `r`.`id`) AS `NbHit`,
count(distinct `rk`.`srcEventIndex`, `rk`.`hitTargetName`) AS `NbKilled`
FROM `sessions` `s`
LEFT JOIN `participates` `ps` ON `s`.`id` = `ps`.`sessionId`
LEFT JOIN `participates` `ph` ON `s`.`id` = `ph`.`sessionId`
LEFT JOIN `triggerevents` `t` ON `s`.`id` = `t`.`sessionId`
LEFT JOIN `triggereventtypes` `tt` ON `tt`.`id` = `t`.`type`
LEFT JOIN `sessiontypes` `st` ON `st`.`id` = `s`.`sessionType`
LEFT JOIN `reactevents` `r` ON `t`.`indexCount` = `r`.`srcEventIndex`
AND `t`.`sessionId` = `r`.`srcEventSessionId`
LEFT JOIN `users` `uh` ON `uh`.`id` = `r`.`hitUserId`
LEFT JOIN `users` `us` ON `us`.`id` = `t`.`srcUserId`
OR `us`.`id` = `ps`.`userId`
LEFT JOIN `reacteventtypes` `rt` ON `rt`.`id` = `r`.`reactType`
LEFT JOIN `reacteventmodes` `rm` ON `rm`.`id` = coalesce(`r`.`reactMode`, 2)
LEFT JOIN `reactevents` `rk` ON `r`.`id` = `rk`.`id` AND `rk`.`targetKilled` = 1
GROUP BY
`s`.`id`,
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)),
`t`.`indexCount`,
`r`.`hitTargetName`
ORDER BY
`s`.`id` ASC,
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)) ASC,
`t`.`indexCount` ASC,
`r`.`id` ASC;

View File

@@ -0,0 +1,11 @@
-- v1.4.5 : ajoute le SessionType id=7 "LongRange" et renomme id=6 en "Rescue".
-- Idempotent :
-- - INSERT IGNORE skip si la PK id=7 existe déjà (cas où 0001_Init.sql récent
-- contient déjà LongRange dans son INSERT initial)
-- - UPDATE ne génère pas d'erreur si aucune ligne ne match (no-op silencieux)
USE proserveapi;
INSERT IGNORE INTO `sessiontypes` (`id`, `displayName`, `description`)
VALUES (7, 'LongRange', '');
UPDATE `sessiontypes` SET `displayName` = 'Rescue' WHERE `id` = 6;

View File

@@ -0,0 +1,7 @@
-- v1.5.0 : ajoute la colonne `size` à la table `users` (taille en cm).
-- Idempotent : `IF NOT EXISTS` skip silencieusement si la colonne existe déjà
-- (cas d'une install fresh où 0001_Init.sql l'a déjà créée).
USE proserveapi;
ALTER TABLE `users`
ADD COLUMN IF NOT EXISTS `size` INT NOT NULL DEFAULT 175;