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:
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() ?>
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
15
server/migrations/003_settings_lock.sql
Normal file
15
server/migrations/003_settings_lock.sql
Normal 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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ using PSLauncher.Core.Integrity;
|
||||
using PSLauncher.Core.Licensing;
|
||||
using PSLauncher.Core.Localization;
|
||||
using PSLauncher.Core.Manifests;
|
||||
using PSLauncher.Core.Security;
|
||||
using PSLauncher.Core.ApiTool;
|
||||
using PSLauncher.Core.DocTool;
|
||||
using PSLauncher.Core.Lan;
|
||||
@@ -33,6 +34,13 @@ namespace PSLauncher.App;
|
||||
public partial class App : Application
|
||||
{
|
||||
private IHost? _host;
|
||||
|
||||
/// <summary>
|
||||
/// IServiceProvider du host DI. Exposé pour les rares cas où une vue est
|
||||
/// instanciée par <c>new</c> hors du container (ex: SettingsDialog) et a
|
||||
/// besoin de résoudre un service.
|
||||
/// </summary>
|
||||
public IServiceProvider? HostServices => _host?.Services;
|
||||
private static System.Threading.Mutex? _singleInstanceMutex;
|
||||
|
||||
private const string SingleInstanceMutexName = "Global\\PSLauncher-3F8E2C1A-9B47-4D5E-A0F8-2E9D1B6C7A3F";
|
||||
@@ -175,6 +183,7 @@ public partial class App : Application
|
||||
services.AddSingleton<IDownloadStateStore, DownloadStateStore>();
|
||||
|
||||
services.AddSingleton<IManifestCache, ManifestCache>();
|
||||
services.AddSingleton<ISettingsLockService, SettingsLockService>();
|
||||
services.AddSingleton<IManifestService>(sp =>
|
||||
new ManifestService(
|
||||
sp.GetRequiredService<HttpClient>(),
|
||||
@@ -186,6 +195,7 @@ public partial class App : Application
|
||||
() => sp.GetRequiredService<ILicenseService>().GetCached()?.Channel,
|
||||
sp.GetRequiredService<IManifestCache>(),
|
||||
sp.GetRequiredService<IPeerManifestFetcher>(),
|
||||
sp.GetRequiredService<ISettingsLockService>(),
|
||||
sp.GetRequiredService<ILogger<ManifestService>>()));
|
||||
|
||||
services.AddSingleton<IDownloadManager, DownloadManager>();
|
||||
@@ -198,6 +208,7 @@ public partial class App : Application
|
||||
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
||||
sp.GetRequiredService<IConfigStore>(),
|
||||
sp.GetRequiredService<LocalConfig>(),
|
||||
sp.GetRequiredService<ISettingsLockService>(),
|
||||
sp.GetRequiredService<ILogger<LicenseService>>()));
|
||||
|
||||
services.AddSingleton<IToastService, ToastService>();
|
||||
|
||||
@@ -18,9 +18,9 @@
|
||||
<Product>PROSERVE Launcher</Product>
|
||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||
<Version>0.27.4</Version>
|
||||
<AssemblyVersion>0.27.4.0</AssemblyVersion>
|
||||
<FileVersion>0.27.4.0</FileVersion>
|
||||
<Version>0.28.10</Version>
|
||||
<AssemblyVersion>0.28.10.0</AssemblyVersion>
|
||||
<FileVersion>0.28.10.0</FileVersion>
|
||||
|
||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
|
||||
@@ -80,6 +80,17 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
/// </summary>
|
||||
private Task? _activeInstallTask;
|
||||
|
||||
/// <summary>
|
||||
/// Référence au process PROSERVE lancé par le launcher (la PLUS RÉCENTE).
|
||||
/// Sert de garde-fou contre les double-lancements : avant chaque
|
||||
/// <see cref="LaunchVersion"/>, on vérifie que ce champ est null ou que le
|
||||
/// process a terminé. Le hook Exited remet le champ à null pour autoriser
|
||||
/// le prochain lancement (manuel ou auto). Couvre uniquement les instances
|
||||
/// LANCÉES PAR NOUS — pour détecter une instance lancée hors launcher, on
|
||||
/// délègue à <see cref="IProcessLauncher.IsRunning(InstalledVersion)"/>.
|
||||
/// </summary>
|
||||
private System.Diagnostics.Process? _runningProserve;
|
||||
|
||||
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -430,6 +441,14 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
},
|
||||
Application.Current.Dispatcher);
|
||||
_lanInfoRefreshTimer.Start();
|
||||
|
||||
// Mode auto : si une version est désignée + feature activée, on la lance
|
||||
// automatiquement après que la UI soit prête (Dispatcher.BeginInvoke différé
|
||||
// pour que MainWindow ait fini son OnLoaded). Le grace period via popup modal
|
||||
// sera affiché par-dessus la fenêtre normalement.
|
||||
Application.Current.Dispatcher.BeginInvoke(
|
||||
new Action(TryAutoLaunchAtStartup),
|
||||
System.Windows.Threading.DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -607,6 +626,72 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
||||
row.OpenFolderHandler = OpenVersionFolder;
|
||||
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
|
||||
row.ToggleAutoModeHandler = ToggleAutoModeForRow;
|
||||
|
||||
// Hydrate l'état mode auto depuis la config persistée (à chaque RebuildList)
|
||||
row.ShowAutoButton = _config.AutoMode.FeatureEnabled && row.IsInstalled;
|
||||
row.IsAutoModeSelected = string.Equals(
|
||||
_config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Click sur le bouton AUTO d'une row. Toggle l'état sur cette row uniquement,
|
||||
/// désélectionne toutes les autres (unicité), persiste la config. Si on toggle
|
||||
/// OFF la version actuellement sélectionnée, SelectedVersion devient null
|
||||
/// (= mode auto inactif mais feature toujours activée pour permettre une nouvelle
|
||||
/// sélection plus tard).
|
||||
/// </summary>
|
||||
private void ToggleAutoModeForRow(VersionRowViewModel row)
|
||||
{
|
||||
// Trace fichier (en plus du Serilog principal) pour pouvoir diagnostiquer le
|
||||
// path d'invocation depuis le bouton AUTO sans dépendre du logger DI.
|
||||
try
|
||||
{
|
||||
var dir = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PSLauncher", "logs");
|
||||
System.IO.Directory.CreateDirectory(dir);
|
||||
System.IO.File.AppendAllText(
|
||||
System.IO.Path.Combine(dir, "auto-mode.log"),
|
||||
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z ToggleAutoModeForRow v{row.Version} (FeatureEnabled={_config.AutoMode.FeatureEnabled}, IsInstalled={row.IsInstalled}, current SelectedVersion={_config.AutoMode.SelectedVersion}){Environment.NewLine}");
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (!_config.AutoMode.FeatureEnabled)
|
||||
{
|
||||
_logger.LogInformation("Auto-mode toggle ignored : FeatureEnabled=false");
|
||||
return;
|
||||
}
|
||||
if (!row.IsInstalled)
|
||||
{
|
||||
_logger.LogInformation("Auto-mode toggle ignored : v{Version} pas installée", row.Version);
|
||||
return;
|
||||
}
|
||||
|
||||
var newSelected = !row.IsAutoModeSelected;
|
||||
|
||||
// Désélectionne toutes les autres
|
||||
if (FeaturedVersion is not null)
|
||||
FeaturedVersion.IsAutoModeSelected = false;
|
||||
foreach (var r in OtherVersions)
|
||||
r.IsAutoModeSelected = false;
|
||||
|
||||
// Sélectionne celle-ci (ou laisse désélectionnée si on a toggle OFF)
|
||||
row.IsAutoModeSelected = newSelected;
|
||||
_config.AutoMode.SelectedVersion = newSelected ? row.Version : null;
|
||||
_configStore.Save(_config);
|
||||
_logger.LogInformation("Auto-mode selection: {Version}",
|
||||
newSelected ? row.Version : "(none)");
|
||||
|
||||
// Si on vient d'activer le mode auto sur cette version, déclenche immédiatement
|
||||
// le countdown + lancement. Différé via Dispatcher.BeginInvoke pour que le
|
||||
// bouton ait le temps de repeindre en orange AVANT que la modal apparaisse.
|
||||
if (newSelected)
|
||||
{
|
||||
Application.Current.Dispatcher.BeginInvoke(
|
||||
new Action(() => StartAutoLaunchCountdown(row)),
|
||||
System.Windows.Threading.DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -897,9 +982,48 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
private void LaunchVersion(VersionRowViewModel row)
|
||||
{
|
||||
if (row.Installed is null) return;
|
||||
|
||||
// ---- Garde-fou anti double-lancement ----
|
||||
// Deux niveaux de vérif pour couvrir tous les cas :
|
||||
// 1. _runningProserve : process LANCÉ PAR NOUS encore vivant ⇒ silently
|
||||
// refuse (cas typique : exit event glitché, ou double-clic du bouton
|
||||
// LANCER, ou auto-mode qui re-déclenche pendant qu'on tourne déjà).
|
||||
// 2. _processLauncher.IsRunning(row.Installed) : scan système pour
|
||||
// détecter une instance lancée HORS launcher (ex. opérateur a
|
||||
// double-cliqué le .exe dans l'explorateur).
|
||||
// En cas de match, on log + popup. La popup n'apparaît PAS pour les
|
||||
// déclenchements auto-mode (silently skip) pour ne pas spammer.
|
||||
if (IsAnyProserveAlreadyRunning(row.Installed, out var detail))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Refusing to launch PROSERVE v{Version} : an instance is already running ({Detail})",
|
||||
row.Version, detail);
|
||||
// Distinction manuel vs auto-mode : pour un clic LANCER manuel,
|
||||
// l'opérateur doit savoir pourquoi rien ne se passe ⇒ popup.
|
||||
// Pour un auto-trigger (countdown finish, exit retrigger…), silently
|
||||
// skip suffit — pas d'interaction utilisateur en cours.
|
||||
if (!_isAutoLaunchInFlight)
|
||||
{
|
||||
ThemedMessageBox.Show(
|
||||
Strings.MsgAlreadyRunningBody,
|
||||
Strings.MsgAlreadyRunningTitle,
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var proc = _processLauncher.Launch(row.Installed);
|
||||
// Si on lance la version désignée comme "auto", on passe les CLI args
|
||||
// configurés (ex: -autoconnect -login=Jerome2 -password=… -ipserver=10.0.4.100).
|
||||
// Pour les lancements manuels d'autres versions, args = null (comportement standard).
|
||||
var isAutoVersion = _config.AutoMode.FeatureEnabled
|
||||
&& !string.IsNullOrEmpty(_config.AutoMode.SelectedVersion)
|
||||
&& string.Equals(_config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal);
|
||||
string[]? cliArgs = isAutoVersion ? BuildAutoModeCliArgs(_config.AutoMode.Args) : null;
|
||||
|
||||
var proc = _processLauncher.Launch(row.Installed, cliArgs);
|
||||
_runningProserve = proc;
|
||||
_config.LastLaunchedVersion = row.Version;
|
||||
_configStore.Save(_config);
|
||||
|
||||
@@ -917,6 +1041,13 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_logger.LogInformation("PROSERVE v{Version} a quitté — restauration du launcher", row.Version);
|
||||
Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
// Libère le slot AVANT de potentiellement relancer en mode auto
|
||||
// (sinon le garde-fou ci-dessus refuserait le nouveau launch).
|
||||
// Compare la référence pour éviter de nuller un process plus récent
|
||||
// dans un scénario rare où le user a relancé manuellement entre temps.
|
||||
if (ReferenceEquals(_runningProserve, proc))
|
||||
_runningProserve = null;
|
||||
|
||||
if (Application.Current.MainWindow is { } main)
|
||||
{
|
||||
if (main.WindowState == WindowState.Minimized)
|
||||
@@ -926,23 +1057,252 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
main.Topmost = true;
|
||||
main.Topmost = false;
|
||||
}
|
||||
// Si la version qui vient de quitter est la version auto, on
|
||||
// déclenche le countdown de relance. L'opérateur peut annuler
|
||||
// (désactive le mode auto sur cette session) ou laisser le timer
|
||||
// expirer pour relancer.
|
||||
if (isAutoVersion)
|
||||
StartAutoLaunchCountdown(row);
|
||||
});
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// ShellExecute peut renvoyer un Process sans accès aux events — non bloquant
|
||||
// ShellExecute peut renvoyer un Process sans accès aux events — non bloquant.
|
||||
// Conséquence : _runningProserve ne sera jamais nullé via Exited et resterait
|
||||
// bloquant. On clear immédiatement pour ne pas verrouiller les futurs launches
|
||||
// (au pire on perd la protection sur cette instance, mais IsRunning() scanne
|
||||
// le système et catchera quand même les doublons).
|
||||
_logger.LogDebug(ex, "Could not hook Exited on launched process");
|
||||
_runningProserve = null;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Launch failed");
|
||||
_runningProserve = null;
|
||||
ThemedMessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
|
||||
Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True quand un lancement auto-mode est en cours de déclenchement (countdown
|
||||
/// en cours OU appel <see cref="LaunchVersion"/> issu du dialog). Sert à
|
||||
/// rendre le garde-fou silencieux pour les triggers auto (sinon une popup
|
||||
/// "déjà lancé" apparaîtrait à chaque tick d'un exit-event glitché en mode
|
||||
/// auto). Toggled par <see cref="StartAutoLaunchCountdown"/>.
|
||||
/// </summary>
|
||||
private bool _isAutoLaunchInFlight;
|
||||
|
||||
/// <summary>
|
||||
/// True si une instance PROSERVE tourne déjà — soit lancée par nous
|
||||
/// (<see cref="_runningProserve"/> encore vivant), soit lancée hors launcher
|
||||
/// et détectée via <see cref="IProcessLauncher.IsRunning"/>. Le paramètre
|
||||
/// <paramref name="detail"/> est rempli avec le motif de blocage pour les logs.
|
||||
/// </summary>
|
||||
private bool IsAnyProserveAlreadyRunning(InstalledVersion target, out string detail)
|
||||
{
|
||||
// 1) Notre process tracking
|
||||
if (_runningProserve is { } p)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!p.HasExited)
|
||||
{
|
||||
detail = $"_runningProserve PID={p.Id} still alive";
|
||||
return true;
|
||||
}
|
||||
// Process a terminé mais Exited n'a pas (encore) fired ⇒ on
|
||||
// libère le slot et continue à vérifier le système.
|
||||
_runningProserve = null;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Process déjà disposé → considère terminé
|
||||
_runningProserve = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Sandboxing ou perte d'accès au process : on ne peut pas savoir.
|
||||
// Mieux vaut être permissif (laisser passer) que bloquer à tort.
|
||||
_logger.LogDebug(ex, "Could not poll _runningProserve.HasExited, treating as exited");
|
||||
_runningProserve = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Scan système pour les instances externes (ou nôtres dont on a perdu
|
||||
// le handle, ex. après un crash launcher partiel).
|
||||
try
|
||||
{
|
||||
if (_processLauncher.IsRunning(target))
|
||||
{
|
||||
detail = $"system scan detected running {Path.GetFileName(target.ExecutablePath)}";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "IsRunning() scan failed, treating as not running");
|
||||
}
|
||||
|
||||
detail = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convertit la liste <see cref="AutoModeConfig.Args"/> en string[] format CLI
|
||||
/// compatible Unreal Engine (FParse::Param et FParse::Value).
|
||||
///
|
||||
/// L'opérateur saisit la liste de paires {Key, Value} sans avoir à se soucier
|
||||
/// de la syntaxe : il tape <c>autoconnect</c> (sans tiret) et <c>login</c> /
|
||||
/// <c>Jerome</c>. Le launcher se charge d'ajouter les <c>-</c>, <c>=</c> et
|
||||
/// le quotage des valeurs avec espaces.
|
||||
///
|
||||
/// Règles de formatage produites :
|
||||
/// • Value vide → <c>-{key}</c> (flag : FParse::Param)
|
||||
/// • Value sans espace → <c>-{key}={value}</c> (FParse::Value classique)
|
||||
/// • Value avec espace → <c>-{key}="value spaced"</c> (quotage Win32)
|
||||
///
|
||||
/// Défensif : on strip un éventuel <c>-</c> en tête de Key et un <c>=</c> en
|
||||
/// tête de Value au cas où l'opérateur les a saisis dans l'UI ("au cas où" =
|
||||
/// retour utilisateur : certains tapent <c>-login</c> ou <c>=Jerome</c> par
|
||||
/// réflexe et obtenaient <c>--login</c> ou <c>-login==Jerome</c> à l'exécution).
|
||||
/// Les clés vides sont skippées silencieusement.
|
||||
/// </summary>
|
||||
private static string[] BuildAutoModeCliArgs(IEnumerable<AutoModeArg> args)
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var a in args)
|
||||
{
|
||||
// Sanitize Key : trim whitespace + leading dashes éventuels.
|
||||
var key = (a.Key ?? string.Empty).Trim().TrimStart('-').Trim();
|
||||
if (string.IsNullOrEmpty(key)) continue;
|
||||
|
||||
// Sanitize Value : trim whitespace + un éventuel `=` en tête.
|
||||
var val = (a.Value ?? string.Empty).Trim().TrimStart('=').Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(val))
|
||||
{
|
||||
list.Add($"-{key}");
|
||||
}
|
||||
else if (val.IndexOfAny(new[] { ' ', '\t', '"' }) >= 0)
|
||||
{
|
||||
// Quote + escape les " internes (rare mais possible pour un mdp).
|
||||
var escaped = val.Replace("\"", "\\\"");
|
||||
list.Add($"-{key}=\"{escaped}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
list.Add($"-{key}={val}");
|
||||
}
|
||||
}
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Point d'entrée UNIQUE pour le lancement auto avec countdown. Utilisé par :
|
||||
/// 1. Clic sur bouton AUTO → désigne la version + lance après countdown
|
||||
/// 2. Startup du launcher → si une version était désignée, idem countdown + launch
|
||||
/// 3. Exit de PROSERVE en mode auto → countdown + relance
|
||||
///
|
||||
/// Comportement :
|
||||
/// - Affiche un popup modal avec compte-à-rebours (3-5 s configurables)
|
||||
/// - Countdown expire → <see cref="LaunchVersion"/> avec args CLI mode auto
|
||||
/// - Annuler → désactive le mode auto (<c>SelectedVersion=null</c>, bouton AUTO
|
||||
/// repasse en gris sur la row CURRENT, persisté dans config.json)
|
||||
/// </summary>
|
||||
private void StartAutoLaunchCountdown(VersionRowViewModel row)
|
||||
{
|
||||
if (row.Installed is null) return;
|
||||
|
||||
// Garde-fou tôt : si PROSERVE est déjà lancé, ne pas afficher de countdown.
|
||||
// Sinon le countdown défile → LaunchVersion → refus silencieux → confusing.
|
||||
// On log simplement et on désactive le mode auto pour cette session pour
|
||||
// que l'opérateur reprenne explicitement la main (sinon un exit-event
|
||||
// glitché pourrait re-trigger ce path en boucle).
|
||||
if (IsAnyProserveAlreadyRunning(row.Installed, out var why))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Auto-launch countdown skipped for v{Version} : already running ({Why})",
|
||||
row.Version, why);
|
||||
return;
|
||||
}
|
||||
|
||||
var grace = Math.Clamp(_config.AutoMode.GracePeriodSeconds, 1, 60);
|
||||
|
||||
// Callback santé : non-null SEULEMENT si l'option WaitForHealthChecks
|
||||
// est activée ET qu'il y a au moins un indicateur configuré. Sinon le
|
||||
// dialog saute la phase santé et démarre direct le countdown.
|
||||
Func<IReadOnlyList<string>>? healthSnapshot = null;
|
||||
if (_config.AutoMode.WaitForHealthChecks && HealthIndicators.Count > 0)
|
||||
{
|
||||
healthSnapshot = () => HealthIndicators
|
||||
.Where(h => h.Severity != Core.Health.HealthSeverity.Ok)
|
||||
.Select(h => h.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
_isAutoLaunchInFlight = true;
|
||||
try
|
||||
{
|
||||
var dialog = new Views.AutoRelaunchDialog(row.Version, grace, healthSnapshot)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
};
|
||||
var result = dialog.ShowDialog();
|
||||
if (result == true)
|
||||
{
|
||||
_logger.LogInformation("Auto-launching PROSERVE v{Version}", row.Version);
|
||||
LaunchVersion(row);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cancel : désactive le mode auto sur cette version. L'opérateur peut
|
||||
// reprendre la main et re-cliquer plus tard pour réactiver.
|
||||
_logger.LogInformation("Auto-launch cancelled by user for v{Version}", row.Version);
|
||||
_config.AutoMode.SelectedVersion = null;
|
||||
_configStore.Save(_config);
|
||||
|
||||
// Trouve la row CURRENT (RebuildList a pu remplacer l'instance entre temps
|
||||
// — auquel cas `row` est orpheline et le bouton de la UI ne se mettrait
|
||||
// pas à jour). Pour le visuel, on désélectionne TOUTES les rows par sûreté.
|
||||
if (FeaturedVersion is not null)
|
||||
FeaturedVersion.IsAutoModeSelected = false;
|
||||
foreach (var r in OtherVersions)
|
||||
r.IsAutoModeSelected = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isAutoLaunchInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Au démarrage du launcher, si le mode auto est configuré avec une version
|
||||
/// sélectionnée ET que cette version est bien installée, on déclenche le
|
||||
/// countdown + lancement (avec possibilité d'annuler via Cancel sur la modal).
|
||||
/// Appelé depuis le constructeur après RebuildList().
|
||||
/// </summary>
|
||||
private void TryAutoLaunchAtStartup()
|
||||
{
|
||||
if (!_config.AutoMode.FeatureEnabled) return;
|
||||
var target = _config.AutoMode.SelectedVersion;
|
||||
if (string.IsNullOrEmpty(target)) return;
|
||||
|
||||
var row = (FeaturedVersion?.Version == target ? FeaturedVersion : null)
|
||||
?? OtherVersions.FirstOrDefault(r => r.Version == target);
|
||||
if (row is null || !row.IsInstalled)
|
||||
{
|
||||
_logger.LogInformation("Auto-launch skipped: v{Version} not installed locally", target);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Auto-launching v{Version} at startup (with countdown)", target);
|
||||
StartAutoLaunchCountdown(row);
|
||||
}
|
||||
|
||||
private async Task InstallVersionAsync(VersionRowViewModel row)
|
||||
{
|
||||
if (row.Remote is null) return;
|
||||
@@ -1065,6 +1425,24 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
// au format progress « ⬇ v1.4.6 : X / 14 Go • Y Mo/s » dès le premier rapport.
|
||||
var zipPath = await _downloadManager.DownloadAsync(job, dlProgress, hashProgress, ct);
|
||||
|
||||
// Promote au cache LAN IMMÉDIATEMENT après le DL (avant l'extract local).
|
||||
// Pourquoi avant : sans ça, le sidecar SHA n'existe qu'après l'install complet
|
||||
// (extract + redist + deploy ~ 20-60 s), pendant lesquelles les autres PCs
|
||||
// qui probe /v1/has/{version} reçoivent 404 et tombent sur OVH inutilement.
|
||||
// En promotant ici, dès que notre DL OVH est fini, on devient seed LAN pour
|
||||
// les autres pendant que notre propre install local continue en parallèle.
|
||||
try
|
||||
{
|
||||
await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct);
|
||||
var cachedNow = _zipCacheStore.ListCached().Select(c => c.Version).ToList();
|
||||
_logger.LogInformation("LAN cache after DL (pre-install) : {Count} version(s) [{Versions}]",
|
||||
cachedNow.Count, string.Join(", ", cachedNow));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ZIP cache promote failed (non-critical, LAN serving will lag until next install)");
|
||||
}
|
||||
|
||||
// 4) Install
|
||||
row.State = VersionRowState.Installing;
|
||||
StatusMessage = Strings.StatusInstallingVersion(row.Version);
|
||||
@@ -1101,22 +1479,31 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
// installer. Track via metadata pour ne pas re-runner à chaque réinstall.
|
||||
// Si un installer fail (already-installed retournent souvent un exit code
|
||||
// non-zero), on log mais on continue — l'install PROSERVE ne doit pas être
|
||||
// bloquée par un redist mineur.
|
||||
await InstallRedistsAsync(target, row.Version, ct);
|
||||
// bloquée par un redist mineur. Wrappé en try/catch : si une exception
|
||||
// non gérée fuit (Process.Start qui throw bizarre, UAC denied catastrophique,
|
||||
// …), on ne doit SURTOUT PAS skip le PromoteAsync ci-dessous, sinon les
|
||||
// peers LAN ne verraient pas la version.
|
||||
try
|
||||
{
|
||||
await InstallRedistsAsync(target, row.Version, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Redists install threw an unhandled exception (continuing to cache promote)");
|
||||
}
|
||||
|
||||
// Promote le ZIP au cache LAN (sidecar SHA écrit) puis prune les vieilles
|
||||
// versions selon MaxCachedVersions. Le ZIP est déjà au bon endroit
|
||||
// (DownloadManager.VerifyAndFinalizeAsync l'a Move'd vers `proserve-X.zip`),
|
||||
// donc PromoteAsync est essentiellement un no-op + écriture du sidecar.
|
||||
// Les versions encore installées sont protégées du pruning.
|
||||
// Prune les vieilles versions selon MaxCachedVersions (le Promote a déjà
|
||||
// eu lieu juste après le DL, voir bloc plus haut). On prune ici post-install
|
||||
// pour que les versions encore installées soient protégées (la version qu'on
|
||||
// vient d'installer compte comme "encore installée").
|
||||
try
|
||||
{
|
||||
await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct);
|
||||
await _zipCacheStore.PruneAsync(_config.LanCache.MaxCachedVersions, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ZIP cache promote/prune failed (non-critical)");
|
||||
_logger.LogWarning(ex, "ZIP cache prune failed (non-critical)");
|
||||
}
|
||||
|
||||
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
|
||||
@@ -1288,6 +1675,25 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup explicite de l'ancien row instance AVANT le RebuildList.
|
||||
// Sans ça :
|
||||
// 1. `row.State` reste à `Installing` (jamais resété depuis le step 4
|
||||
// ligne ~1260) → si un binding survit sur cette instance le temps
|
||||
// que WPF rafraîchisse OtherVersions.Clear()+Add(), l'utilisateur
|
||||
// voit brièvement (ou durablement, cf. bug rapporté) une row
|
||||
// « en cours d'installation » à côté du toast "✓ installée".
|
||||
// 2. `_activeRow` pointe encore sur cette OLD instance, ce qui
|
||||
// maintient `HasActiveDownload` truthy le temps que `finally`
|
||||
// tourne — pas grave en pratique mais pas propre.
|
||||
// Le RebuildList va de toute façon créer une NOUVELLE instance
|
||||
// `ForInstalled(...)` pour cette version puisque le scan disque la
|
||||
// détecte maintenant. Cet edit purge l'ancienne instance avant que
|
||||
// les bindings puissent s'y raccrocher.
|
||||
row.State = VersionRowState.InstalledIdle;
|
||||
row.ProgressPercent = 0;
|
||||
row.ProgressDetail = null;
|
||||
_activeRow = null;
|
||||
|
||||
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
||||
ProgressDetail = null;
|
||||
ProgressPercent = 0;
|
||||
|
||||
@@ -116,6 +116,15 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new();
|
||||
public bool HasLanDiscoveredPeers => LanDiscoveredPeers.Count > 0;
|
||||
|
||||
// ----- Mode auto -----
|
||||
[ObservableProperty] private bool _autoModeFeatureEnabled;
|
||||
[ObservableProperty] private int _autoModeGraceSeconds;
|
||||
[ObservableProperty] private string _autoModeSelectedVersion = string.Empty;
|
||||
/// <summary>Si true → countdown attend que tous les health checks soient OK avant de démarrer.</summary>
|
||||
[ObservableProperty] private bool _autoModeWaitForHealthChecks;
|
||||
/// <summary>Args édités dans la UI. Persiste vers <c>_config.AutoMode.Args</c> au Save.</summary>
|
||||
public System.Collections.ObjectModel.ObservableCollection<AutoModeArgRowViewModel> AutoModeArgs { get; } = new();
|
||||
|
||||
// ----- Health checks (bandeau de santé système) -----
|
||||
/// <summary>
|
||||
/// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne
|
||||
@@ -248,6 +257,13 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_apiAutoDeploy = config.ApiTool.AutoDeploy;
|
||||
_apiMaxBackups = config.ApiTool.MaxBackups;
|
||||
|
||||
_autoModeFeatureEnabled = config.AutoMode.FeatureEnabled;
|
||||
_autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds;
|
||||
_autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty;
|
||||
_autoModeWaitForHealthChecks = config.AutoMode.WaitForHealthChecks;
|
||||
foreach (var a in config.AutoMode.Args ?? new List<AutoModeArg>())
|
||||
AutoModeArgs.Add(new AutoModeArgRowViewModel(a.Key, a.Value, RemoveAutoModeArg));
|
||||
|
||||
_lanServerEnabled = config.LanCache.ServerEnabled;
|
||||
_lanClientEnabled = config.LanCache.ClientEnabled;
|
||||
_lanServerPort = config.LanCache.ServerPort;
|
||||
@@ -413,6 +429,20 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
|
||||
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);
|
||||
|
||||
_config.AutoMode.FeatureEnabled = AutoModeFeatureEnabled;
|
||||
_config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60);
|
||||
_config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion)
|
||||
? null : AutoModeSelectedVersion.Trim();
|
||||
_config.AutoMode.WaitForHealthChecks = AutoModeWaitForHealthChecks;
|
||||
_config.AutoMode.Args = AutoModeArgs
|
||||
.Select(r => new AutoModeArg
|
||||
{
|
||||
Key = (r.Key ?? string.Empty).Trim(),
|
||||
Value = string.IsNullOrEmpty(r.Value) ? null : r.Value.Trim(),
|
||||
})
|
||||
.Where(a => !string.IsNullOrEmpty(a.Key))
|
||||
.ToList();
|
||||
|
||||
_config.LanCache.ServerEnabled = LanServerEnabled;
|
||||
_config.LanCache.ClientEnabled = LanClientEnabled;
|
||||
_config.LanCache.ServerPort = LanServerPort > 0 && LanServerPort <= 65535 ? LanServerPort : 47623;
|
||||
@@ -990,6 +1020,42 @@ public sealed partial class SettingsViewModel : ObservableObject
|
||||
IsDeployingApi = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== AUTO MODE ARGS =====================
|
||||
|
||||
[RelayCommand]
|
||||
private void AddAutoModeArg()
|
||||
{
|
||||
AutoModeArgs.Add(new AutoModeArgRowViewModel(string.Empty, null, RemoveAutoModeArg));
|
||||
}
|
||||
|
||||
private void RemoveAutoModeArg(AutoModeArgRowViewModel row)
|
||||
{
|
||||
AutoModeArgs.Remove(row);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ligne éditable d'un argument CLI passé à PROSERVE en mode auto. Clé requise,
|
||||
/// valeur optionnelle (un flag seul comme <c>-autoconnect</c>). Persiste vers
|
||||
/// <c>_config.AutoMode.Args</c> au Save.
|
||||
/// </summary>
|
||||
public sealed partial class AutoModeArgRowViewModel : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _key;
|
||||
[ObservableProperty] private string? _value;
|
||||
|
||||
private readonly Action<AutoModeArgRowViewModel> _onRemove;
|
||||
|
||||
public AutoModeArgRowViewModel(string key, string? value, Action<AutoModeArgRowViewModel> onRemove)
|
||||
{
|
||||
_key = key ?? string.Empty;
|
||||
_value = value;
|
||||
_onRemove = onRemove;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Remove() => _onRemove(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -71,6 +71,44 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
||||
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
|
||||
private bool _licenseAllowsDownload = true;
|
||||
|
||||
/// <summary>
|
||||
/// True quand cette version est la version désignée pour l'auto-launch
|
||||
/// (bouton AUTO orange/coloré). Une seule row peut être sélectionnée à la
|
||||
/// fois ; le MainViewModel maintient l'unicité quand on toggle.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private bool _isAutoModeSelected;
|
||||
|
||||
/// <summary>
|
||||
/// True si le bouton AUTO doit être affiché sur cette row (master toggle ON
|
||||
/// + version installée). Piloté par le MainViewModel via property update à
|
||||
/// chaque RebuildList.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
private bool _showAutoButton;
|
||||
|
||||
public Action<VersionRowViewModel>? ToggleAutoModeHandler { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleAutoMode()
|
||||
{
|
||||
// Trace fichier pour diagnostiquer "le bouton AUTO ne fait rien".
|
||||
// Si on voit cette ligne mais pas la suivante côté MainViewModel,
|
||||
// c'est que le handler n'est pas attaché (bug WireRowHandlers).
|
||||
try
|
||||
{
|
||||
var dir = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PSLauncher", "logs");
|
||||
System.IO.Directory.CreateDirectory(dir);
|
||||
System.IO.File.AppendAllText(
|
||||
System.IO.Path.Combine(dir, "auto-mode.log"),
|
||||
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z VersionRowViewModel.ToggleAutoMode invoked for v{Version} (handler attached={ToggleAutoModeHandler is not null}){Environment.NewLine}");
|
||||
}
|
||||
catch { }
|
||||
ToggleAutoModeHandler?.Invoke(this);
|
||||
}
|
||||
|
||||
public bool HasResumableDownload => ResumableBytes > 0;
|
||||
public bool LicenseAllowsInstall => LicenseAllowsDownload;
|
||||
|
||||
|
||||
72
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml
Normal file
72
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml
Normal file
@@ -0,0 +1,72 @@
|
||||
<Window x:Class="PSLauncher.App.Views.AutoRelaunchDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||
Title="{x:Static loc:Strings.AutoRelaunchTitle}"
|
||||
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||
Width="560" Height="420"
|
||||
ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Topmost="True"
|
||||
Background="{StaticResource Brush.Bg.Window}">
|
||||
<Grid Margin="36,32">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Titre dynamique : "Vérification de la santé système" pendant la
|
||||
phase d'attente, "Lancement automatique" pendant le countdown. -->
|
||||
<TextBlock Grid.Row="0"
|
||||
x:Name="TitleText"
|
||||
Text="{x:Static loc:Strings.AutoRelaunchTitle}"
|
||||
FontSize="22" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
HorizontalAlignment="Center" />
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
x:Name="MessageText"
|
||||
FontSize="14"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
Margin="0,16,0,0" />
|
||||
|
||||
<!-- Affiché pendant la phase santé : compteur d'indicateurs en attente
|
||||
+ nom des checks non-OK. Masqué pendant le countdown. -->
|
||||
<TextBlock Grid.Row="2"
|
||||
x:Name="HealthPendingText"
|
||||
FontSize="13"
|
||||
Foreground="#FBBF24"
|
||||
TextWrapping="Wrap"
|
||||
TextAlignment="Center"
|
||||
Margin="0,12,0,0"
|
||||
Visibility="Collapsed" />
|
||||
|
||||
<!-- Affiché pendant le countdown. Gros chiffre orange. -->
|
||||
<TextBlock Grid.Row="2"
|
||||
x:Name="CountdownText"
|
||||
FontSize="72"
|
||||
FontWeight="Bold"
|
||||
HorizontalAlignment="Center"
|
||||
Foreground="{StaticResource Brush.Accent}"
|
||||
Margin="0,20,0,0"
|
||||
Visibility="Collapsed" />
|
||||
|
||||
<!-- Row 3 = espacement * pour aérer entre le contenu et le bouton -->
|
||||
|
||||
<Button Grid.Row="4"
|
||||
Style="{StaticResource DangerButton}"
|
||||
Content="{x:Static loc:Strings.AutoRelaunchCancel}"
|
||||
FontSize="14"
|
||||
Padding="40,12"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="0,16,0,0"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
Click="OnCancel" />
|
||||
</Grid>
|
||||
</Window>
|
||||
162
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml.cs
Normal file
162
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using PSLauncher.Core.Localization;
|
||||
|
||||
namespace PSLauncher.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Modal de lancement automatique de PROSERVE. Utilisé pour les 3 entry points
|
||||
/// du mode auto :
|
||||
/// 1. Click sur le bouton AUTO d'une row → désigne + lance
|
||||
/// 2. Démarrage du launcher avec mode auto configuré → lance
|
||||
/// 3. PROSERVE quitte alors qu'il était la version auto → relance
|
||||
///
|
||||
/// Deux phases possibles selon <see cref="AutoModeConfig.WaitForHealthChecks"/> :
|
||||
/// • <b>Phase santé</b> (optionnelle) : si la config dit d'attendre que tous
|
||||
/// les indicateurs de santé système soient OK, on poll le callback
|
||||
/// <see cref="_healthSnapshot"/> tant qu'il retourne une liste non vide.
|
||||
/// Le bouton Annuler permet de sortir et désactiver le mode auto.
|
||||
/// • <b>Phase countdown</b> : décompte visuel de N secondes avant lancement
|
||||
/// (configurable via GracePeriodSeconds, clampé 1-60).
|
||||
///
|
||||
/// DialogResult :
|
||||
/// <c>true</c> = phase countdown terminée à zéro, MainViewModel doit lancer
|
||||
/// <c>false</c> = l'opérateur a cliqué Annuler (à n'importe quelle phase),
|
||||
/// MainViewModel doit désactiver SelectedVersion
|
||||
/// </summary>
|
||||
public partial class AutoRelaunchDialog : Window
|
||||
{
|
||||
private readonly DispatcherTimer _timer;
|
||||
private readonly int _graceSeconds;
|
||||
private readonly string _versionName;
|
||||
|
||||
/// <summary>
|
||||
/// Callback fourni par MainViewModel pour interroger l'état de santé.
|
||||
/// Retourne la liste des NOMS d'indicateurs qui ne sont PAS encore OK
|
||||
/// (Warning, Error, ou Unknown). Liste vide ⇒ tout est OK ⇒ on peut
|
||||
/// passer à la phase countdown. Null si la phase santé n'est pas
|
||||
/// demandée pour ce lancement (option WaitForHealthChecks=false ou
|
||||
/// aucun health check configuré).
|
||||
/// </summary>
|
||||
private readonly Func<IReadOnlyList<string>>? _healthSnapshot;
|
||||
|
||||
private enum Phase { HealthWait, Countdown }
|
||||
private Phase _phase;
|
||||
private int _remainingSeconds;
|
||||
|
||||
public AutoRelaunchDialog(string versionName, int graceSeconds,
|
||||
Func<IReadOnlyList<string>>? healthSnapshot = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
_versionName = versionName;
|
||||
_graceSeconds = Math.Clamp(graceSeconds, 1, 60);
|
||||
_remainingSeconds = _graceSeconds;
|
||||
_healthSnapshot = healthSnapshot;
|
||||
|
||||
// Si on a un callback santé ET qu'au premier check il y a des
|
||||
// indicateurs en attente, on démarre en phase santé. Sinon on saute
|
||||
// direct au countdown (cas WaitForHealthChecks=false OU tout est
|
||||
// déjà OK au moment où le dialog s'ouvre).
|
||||
var initialPending = _healthSnapshot?.Invoke() ?? Array.Empty<string>();
|
||||
if (initialPending.Count > 0)
|
||||
{
|
||||
_phase = Phase.HealthWait;
|
||||
EnterHealthWaitPhase(initialPending);
|
||||
}
|
||||
else
|
||||
{
|
||||
_phase = Phase.Countdown;
|
||||
EnterCountdownPhase();
|
||||
}
|
||||
|
||||
// Un seul timer pour les deux phases : poll santé toutes les 1s ou
|
||||
// décrémente le countdown toutes les 1s. Simplifie le cleanup.
|
||||
_timer = new DispatcherTimer(DispatcherPriority.Normal)
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1),
|
||||
};
|
||||
_timer.Tick += OnTick;
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
private void EnterHealthWaitPhase(IReadOnlyList<string> pending)
|
||||
{
|
||||
TitleText.Text = Strings.AutoLaunchHealthWaitTitle;
|
||||
MessageText.Text = Strings.AutoLaunchHealthWaitMessage;
|
||||
HealthPendingText.Visibility = Visibility.Visible;
|
||||
CountdownText.Visibility = Visibility.Collapsed;
|
||||
UpdatePendingText(pending);
|
||||
}
|
||||
|
||||
private void EnterCountdownPhase()
|
||||
{
|
||||
TitleText.Text = Strings.AutoRelaunchTitle;
|
||||
MessageText.Text = Strings.AutoRelaunchMessage(_versionName);
|
||||
HealthPendingText.Visibility = Visibility.Collapsed;
|
||||
CountdownText.Visibility = Visibility.Visible;
|
||||
_remainingSeconds = _graceSeconds;
|
||||
UpdateCountdown();
|
||||
}
|
||||
|
||||
private void UpdatePendingText(IReadOnlyList<string> pending)
|
||||
{
|
||||
// Ligne 1 : "Indicateurs en attente : N". Ligne 2 : noms séparés par ' • '.
|
||||
// On limite à 4 noms affichés pour ne pas casser le layout si l'opérateur
|
||||
// a configuré 15 checks. Le tooltip système couvre le détail complet.
|
||||
var header = Strings.AutoLaunchHealthWaitPending(pending.Count);
|
||||
var preview = pending.Count <= 4
|
||||
? string.Join(" • ", pending)
|
||||
: string.Join(" • ", pending.Take(4)) + " …";
|
||||
HealthPendingText.Text = $"{header}\n{preview}";
|
||||
}
|
||||
|
||||
private void OnTick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_phase == Phase.HealthWait)
|
||||
{
|
||||
var pending = _healthSnapshot?.Invoke() ?? Array.Empty<string>();
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
// Transition vers countdown : tout est OK maintenant.
|
||||
_phase = Phase.Countdown;
|
||||
EnterCountdownPhase();
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdatePendingText(pending);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase.Countdown
|
||||
_remainingSeconds--;
|
||||
if (_remainingSeconds <= 0)
|
||||
{
|
||||
_timer.Stop();
|
||||
DialogResult = true; // lancement
|
||||
Close();
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateCountdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCountdown()
|
||||
{
|
||||
CountdownText.Text = _remainingSeconds.ToString();
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_timer.Stop();
|
||||
DialogResult = false; // annulation
|
||||
Close();
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
_timer.Stop();
|
||||
base.OnClosed(e);
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,27 @@
|
||||
|
||||
<!-- Action button -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0">
|
||||
<!-- Bouton AUTO : sélectionne cette version pour l'auto-launch
|
||||
au démarrage du launcher. Fond gris quand inactif, orange
|
||||
quand sélectionné. Visible seulement si FeatureEnabled. -->
|
||||
<Button Content="{x:Static loc:Strings.ActionAuto}"
|
||||
Padding="14,8" FontSize="13" FontWeight="Bold"
|
||||
Margin="0,0,6,0"
|
||||
Command="{Binding ToggleAutoModeCommand}"
|
||||
Visibility="{Binding ShowAutoButton, Converter={StaticResource BoolToVisibility}}">
|
||||
<Button.Style>
|
||||
<Style TargetType="Button" BasedOn="{StaticResource SecondaryButton}">
|
||||
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipInactive}" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsAutoModeSelected}" Value="True">
|
||||
<Setter Property="Background" Value="#F5A623" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipActive}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Button.Style>
|
||||
</Button>
|
||||
<Button Style="{StaticResource PrimaryButton}"
|
||||
Content="{x:Static loc:Strings.ActionLaunch}" Padding="22,8" FontSize="13"
|
||||
Command="{Binding LaunchCommand}"
|
||||
@@ -527,13 +548,34 @@
|
||||
</TextBlock>
|
||||
|
||||
<!-- Action principale (gros bouton) -->
|
||||
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
||||
Style="{StaticResource PrimaryButton}"
|
||||
Content="{x:Static loc:Strings.ActionLaunchBig}"
|
||||
FontSize="20" Padding="56,16"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding FeaturedVersion.LaunchCommand}"
|
||||
Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}" />
|
||||
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
||||
Orientation="Horizontal" VerticalAlignment="Center"
|
||||
Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}">
|
||||
<!-- Bouton AUTO featured (taille assortie au gros LANCER) -->
|
||||
<Button Content="{x:Static loc:Strings.ActionAuto}"
|
||||
FontSize="16" FontWeight="Bold"
|
||||
Padding="20,16"
|
||||
Margin="0,0,12,0"
|
||||
Command="{Binding FeaturedVersion.ToggleAutoModeCommand}"
|
||||
Visibility="{Binding FeaturedVersion.ShowAutoButton, Converter={StaticResource BoolToVisibility}}">
|
||||
<Button.Style>
|
||||
<Style TargetType="Button" BasedOn="{StaticResource SecondaryButton}">
|
||||
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipInactive}" />
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding FeaturedVersion.IsAutoModeSelected}" Value="True">
|
||||
<Setter Property="Background" Value="#F5A623" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipActive}" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Button.Style>
|
||||
</Button>
|
||||
<Button Style="{StaticResource PrimaryButton}"
|
||||
Content="{x:Static loc:Strings.ActionLaunchBig}"
|
||||
FontSize="20" Padding="56,16"
|
||||
Command="{Binding FeaturedVersion.LaunchCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
||||
Orientation="Vertical" VerticalAlignment="Center"
|
||||
|
||||
@@ -174,11 +174,13 @@
|
||||
importantes (License, Langue, About) qui restent dépliées.
|
||||
Placé en BAS de la fenêtre car ce sont des trucs de power-user / support.
|
||||
-->
|
||||
<Expander Header="{x:Static loc:Strings.SettingsAdvanced}"
|
||||
<Expander x:Name="AdvancedExpander"
|
||||
Header="{x:Static loc:Strings.SettingsAdvanced}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="11" FontWeight="Bold"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Margin="0,0,0,16" IsExpanded="False">
|
||||
Margin="0,0,0,16" IsExpanded="False"
|
||||
Expanded="OnAdvancedExpanded">
|
||||
<StackPanel Margin="0,12,0,0">
|
||||
|
||||
<!-- Serveur -->
|
||||
@@ -648,6 +650,94 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Mode auto : auto-launch + auto-relaunch d'une version désignée -->
|
||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="20" Margin="0,0,0,16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{x:Static loc:Strings.SettingsAutoMode}"
|
||||
FontSize="11" FontWeight="Bold"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<CheckBox IsChecked="{Binding AutoModeFeatureEnabled}"
|
||||
Margin="0,4,0,0"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
Content="{x:Static loc:Strings.SettingsAutoModeFeatureEnabled}" />
|
||||
|
||||
<CheckBox IsChecked="{Binding AutoModeWaitForHealthChecks}"
|
||||
Margin="0,8,0,0"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
Content="{x:Static loc:Strings.SettingsAutoModeWaitForHealth}" />
|
||||
|
||||
<Grid Margin="0,12,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="100" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="{x:Static loc:Strings.SettingsAutoModeGrace}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="12" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1"
|
||||
Text="{Binding AutoModeGraceSeconds, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Margin="0,12,0,4"
|
||||
Text="{Binding AutoModeSelectedVersion, StringFormat='Version désignée actuelle : v{0}', FallbackValue='Aucune version désignée'}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="12" FontStyle="Italic" />
|
||||
|
||||
<TextBlock Text="{x:Static loc:Strings.SettingsAutoModeArgs}"
|
||||
FontWeight="Bold" FontSize="11"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
Margin="0,16,0,6" TextWrapping="Wrap" />
|
||||
|
||||
<!-- Liste éditable d'arguments. Chaque ligne : Key + Value (optionnel) + Remove. -->
|
||||
<ItemsControl ItemsSource="{Binding AutoModeArgs}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid Margin="0,0,0,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Grid.Column="0"
|
||||
Text="{Binding Key, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||
FontFamily="Consolas"
|
||||
Tag="{x:Static loc:Strings.SettingsAutoModeArgKey}"
|
||||
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgKey}" />
|
||||
<TextBox Grid.Column="1"
|
||||
Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
|
||||
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||
FontFamily="Consolas"
|
||||
Margin="6,0,6,0"
|
||||
Tag="{x:Static loc:Strings.SettingsAutoModeArgValue}"
|
||||
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgValue}" />
|
||||
<Button Grid.Column="2"
|
||||
Style="{StaticResource SecondaryButton}"
|
||||
Content="{x:Static loc:Strings.SettingsAutoModeArgRemove}"
|
||||
Command="{Binding RemoveCommand}"
|
||||
Padding="10,6" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="{x:Static loc:Strings.SettingsAutoModeArgAdd}"
|
||||
Command="{Binding AddAutoModeArgCommand}"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="0,4,0,0" Padding="12,6" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Cache LAN P2P (peers du LAN partagent les ZIPs entre eux) -->
|
||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PSLauncher.App.ViewModels;
|
||||
using PSLauncher.Core.Security;
|
||||
|
||||
namespace PSLauncher.App.Views;
|
||||
|
||||
@@ -25,4 +30,80 @@ public partial class SettingsDialog : Window
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quand l'opérateur clique pour déplier les paramètres avancés, on vérifie le
|
||||
/// settings lock service. Si verrouillé, on affiche le password prompt. Si Cancel
|
||||
/// ou mauvais mot de passe, on referme l'expander pour que la section reste cachée.
|
||||
///
|
||||
/// Trace écrite dans un fichier dédié (settings-lock.log) à chaque appel pour
|
||||
/// permettre de diagnostiquer les cas "l'expander se déplie pas" sans dépendre
|
||||
/// du Serilog principal (au cas où l'event ne traverserait même pas le code).
|
||||
/// </summary>
|
||||
private void OnAdvancedExpanded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
TraceExpanded("OnAdvancedExpanded fired");
|
||||
if (sender is not Expander exp)
|
||||
{
|
||||
TraceExpanded("sender is not Expander, aborting");
|
||||
return;
|
||||
}
|
||||
|
||||
// Résout le service depuis l'App DI container. Service-locator pattern toléré
|
||||
// ici car SettingsDialog n'est pas injectable directement (instancié par new).
|
||||
var app = (App)Application.Current;
|
||||
var lockService = app.HostServices?.GetService<ISettingsLockService>();
|
||||
if (lockService is null)
|
||||
{
|
||||
TraceExpanded("lockService is null, expander stays open");
|
||||
return;
|
||||
}
|
||||
TraceExpanded($"lockService.HasLockConfigured={lockService.HasLockConfigured} IsLocked={lockService.IsLocked}");
|
||||
if (!lockService.IsLocked)
|
||||
{
|
||||
TraceExpanded("not locked, expander stays open");
|
||||
return;
|
||||
}
|
||||
|
||||
TraceExpanded("locked, showing password dialog");
|
||||
bool? ok = null;
|
||||
try
|
||||
{
|
||||
var dialog = new SettingsLockDialog(lockService) { Owner = this };
|
||||
ok = dialog.ShowDialog();
|
||||
TraceExpanded($"dialog result: {ok}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Toute exception (XAML parse error, missing resource, etc.) doit être
|
||||
// logguée et NE PAS rester silencieuse. Sans ce catch, le bug se manifeste
|
||||
// par "rien ne s'ouvre, mais le log dit que ça devrait" (cas 0.28.2 avec
|
||||
// Brush.Status.Danger inexistant).
|
||||
TraceExpanded($"EXCEPTION creating/showing SettingsLockDialog: {ex.GetType().Name}: {ex.Message}");
|
||||
// On laisse l'expander DÉPLIÉ (ok=null tombera dans le if false ci-dessous
|
||||
// qui le refermerait). Plutôt : on garde déplié pour ne pas frustrer l'user
|
||||
// si le lock dialog est cassé — il aura accès, et le bug doit être réglé.
|
||||
return;
|
||||
}
|
||||
if (ok != true)
|
||||
{
|
||||
// Cancel ou mauvais mot de passe → on referme l'expander.
|
||||
exp.IsExpanded = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraceExpanded(string msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"PSLauncher", "logs");
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "settings-lock.log");
|
||||
File.AppendAllText(path,
|
||||
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z {msg}{Environment.NewLine}");
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
64
src/PSLauncher.App/Views/SettingsLockDialog.xaml
Normal file
64
src/PSLauncher.App/Views/SettingsLockDialog.xaml
Normal file
@@ -0,0 +1,64 @@
|
||||
<Window x:Class="PSLauncher.App.Views.SettingsLockDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||
Title="{x:Static loc:Strings.SettingsLockedTitle}"
|
||||
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||
Width="520" Height="360"
|
||||
ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Topmost="True"
|
||||
ShowInTaskbar="False"
|
||||
Background="{StaticResource Brush.Bg.Window}">
|
||||
<Grid Margin="32,28">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0"
|
||||
FontSize="18" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Brush.Text.Primary}">
|
||||
<Run Text="🔒 " />
|
||||
<Run Text="{x:Static loc:Strings.SettingsLockedTitle}" />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Grid.Row="1"
|
||||
Text="{x:Static loc:Strings.SettingsLockedBody}"
|
||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||
FontSize="12" TextWrapping="Wrap"
|
||||
Margin="0,8,0,12" />
|
||||
|
||||
<PasswordBox Grid.Row="2"
|
||||
x:Name="PasswordInput"
|
||||
Background="#1A1A20"
|
||||
Foreground="{StaticResource Brush.Text.Primary}"
|
||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||
Padding="8" FontSize="14"
|
||||
KeyDown="OnPasswordKeyDown" />
|
||||
|
||||
<TextBlock Grid.Row="3"
|
||||
x:Name="ErrorText"
|
||||
Foreground="#FCA5A5"
|
||||
FontSize="11"
|
||||
Margin="0,4,0,0"
|
||||
Visibility="Collapsed"
|
||||
Text="{x:Static loc:Strings.SettingsLockedWrongPassword}" />
|
||||
|
||||
<StackPanel Grid.Row="4" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Style="{StaticResource SecondaryButton}"
|
||||
Content="{x:Static loc:Strings.ActionCancel}" IsCancel="True"
|
||||
Margin="0,0,12,0"
|
||||
Click="OnCancel" />
|
||||
<Button Style="{StaticResource AccentButton}"
|
||||
Content="{x:Static loc:Strings.SettingsLockedUnlock}"
|
||||
Padding="20,8"
|
||||
IsDefault="True"
|
||||
Click="OnUnlock" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
77
src/PSLauncher.App/Views/SettingsLockDialog.xaml.cs
Normal file
77
src/PSLauncher.App/Views/SettingsLockDialog.xaml.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using PSLauncher.Core.Security;
|
||||
|
||||
namespace PSLauncher.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Dialog de saisie du password qui déverrouille la section Avancés des Settings.
|
||||
/// Le password est hashé en SHA-256 et comparé au hash du manifest signé. L'unlock
|
||||
/// est valable jusqu'à la fermeture du launcher (en-mémoire dans <see cref="ISettingsLockService"/>).
|
||||
///
|
||||
/// DialogResult :
|
||||
/// <c>true</c> = unlock réussi
|
||||
/// <c>false</c> = annulation (Cancel)
|
||||
/// </summary>
|
||||
public partial class SettingsLockDialog : Window
|
||||
{
|
||||
private readonly ISettingsLockService _lock;
|
||||
|
||||
public SettingsLockDialog(ISettingsLockService lockService)
|
||||
{
|
||||
InitializeComponent();
|
||||
_lock = lockService;
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
// Trace pour diagnostiquer un éventuel "le dialog ne s'affiche pas".
|
||||
// Si on voit cette ligne dans settings-lock.log, le dialog est rendu
|
||||
// → le pb est ailleurs (hors écran, derrière owner, etc.).
|
||||
try
|
||||
{
|
||||
var dir = System.IO.Path.Combine(
|
||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
|
||||
"PSLauncher", "logs");
|
||||
System.IO.Directory.CreateDirectory(dir);
|
||||
System.IO.File.AppendAllText(
|
||||
System.IO.Path.Combine(dir, "settings-lock.log"),
|
||||
$"{System.DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z SettingsLockDialog Loaded (visible={IsVisible}, left={Left}, top={Top}){System.Environment.NewLine}");
|
||||
}
|
||||
catch { }
|
||||
Activate();
|
||||
PasswordInput.Focus();
|
||||
};
|
||||
}
|
||||
|
||||
private void OnPasswordKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
TryUnlock();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnUnlock(object sender, RoutedEventArgs e) => TryUnlock();
|
||||
|
||||
private void TryUnlock()
|
||||
{
|
||||
var ok = _lock.TryUnlock(PasswordInput.Password);
|
||||
if (ok)
|
||||
{
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorText.Visibility = Visibility.Visible;
|
||||
PasswordInput.Clear();
|
||||
PasswordInput.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -52,9 +52,21 @@ public sealed class ZipCacheStore : IZipCacheStore
|
||||
var sha = File.ReadAllText(shaPath, Encoding.ASCII).Trim();
|
||||
if (sha.Length != 64) return false; // SHA-256 hex = 64 chars
|
||||
var fi = new FileInfo(zipPath);
|
||||
// Force la lecture des metadata maintenant — si le fichier disparaît
|
||||
// ici (race avec File.Move overwrite=true du DownloadManager), on attrape
|
||||
// FileNotFoundException ci-dessous silencieusement. Sans ce Refresh(),
|
||||
// FileInfo.Length aurait fait l'I/O lazy et throw plus tard.
|
||||
fi.Refresh();
|
||||
entry = new CachedZip(version, zipPath, fi.Length, sha, fi.LastWriteTimeUtc);
|
||||
return true;
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// Race connue et bénigne : un peer client probe /v1/has/{ver} pile au
|
||||
// moment où DownloadManager.VerifyAndFinalizeAsync fait File.Move
|
||||
// (delete-then-rename sur NTFS). Le client retentera ou tombera sur OVH.
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Cache lookup failed for {Version}", version);
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSec.Cryptography;
|
||||
using PSLauncher.Core.Configuration;
|
||||
using PSLauncher.Core.Security;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Licensing;
|
||||
@@ -36,6 +37,7 @@ public sealed class LicenseService : ILicenseService
|
||||
private readonly Func<string> _serverBaseUrlProvider;
|
||||
private readonly IConfigStore _configStore;
|
||||
private readonly LocalConfig _config;
|
||||
private readonly ISettingsLockService _settingsLock;
|
||||
private readonly ILogger<LicenseService> _logger;
|
||||
private readonly string? _serverPublicKeyHex;
|
||||
|
||||
@@ -44,10 +46,12 @@ public sealed class LicenseService : ILicenseService
|
||||
Func<string> serverBaseUrlProvider,
|
||||
IConfigStore configStore,
|
||||
LocalConfig config,
|
||||
ISettingsLockService settingsLock,
|
||||
ILogger<LicenseService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_serverBaseUrlProvider = serverBaseUrlProvider;
|
||||
_settingsLock = settingsLock;
|
||||
_configStore = configStore;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
@@ -130,6 +134,10 @@ public sealed class LicenseService : ILicenseService
|
||||
_logger.LogDebug("License response signature OK");
|
||||
}
|
||||
|
||||
// Propage le hash du mot de passe settings lock vers le service in-memory.
|
||||
// L'intégrité est garantie par la signature Ed25519 vérifiée juste au-dessus.
|
||||
_settingsLock.SetPasswordHash(parsed.SettingsLockPasswordHash);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -206,7 +214,7 @@ public sealed class LicenseService : ILicenseService
|
||||
cachedStatus = "expired";
|
||||
}
|
||||
|
||||
return new LicenseValidationResponse
|
||||
var cached = new LicenseValidationResponse
|
||||
{
|
||||
Status = cachedStatus,
|
||||
OwnerName = _config.License.CachedOwnerName,
|
||||
@@ -219,7 +227,13 @@ public sealed class LicenseService : ILicenseService
|
||||
// jamais les builds spécifiques à son channel (firefighter, police…).
|
||||
Channel = _config.License.CachedChannel,
|
||||
CanSeeBetas = _config.License.CachedCanSeeBetas,
|
||||
SettingsLockPasswordHash = _config.License.CachedSettingsLockPasswordHash,
|
||||
};
|
||||
// Propagation au service in-memory : même au démarrage offline, le hash
|
||||
// cache permet de gater les Settings → Avancés tant qu'on n'a pas pu
|
||||
// revalider online.
|
||||
_settingsLock.SetPasswordHash(cached.SettingsLockPasswordHash);
|
||||
return cached;
|
||||
}
|
||||
|
||||
public void SaveCached(string licenseKey, LicenseValidationResponse response)
|
||||
@@ -236,6 +250,7 @@ public sealed class LicenseService : ILicenseService
|
||||
_config.License.CachedStatus = response.Status;
|
||||
_config.License.CachedChannel = response.Channel;
|
||||
_config.License.CachedCanSeeBetas = response.CanSeeBetas;
|
||||
_config.License.CachedSettingsLockPasswordHash = response.SettingsLockPasswordHash;
|
||||
_configStore.Save(_config);
|
||||
}
|
||||
|
||||
@@ -323,6 +338,7 @@ public sealed class LicenseService : ILicenseService
|
||||
// ATTENTION : ordre CRITIQUE — Crypto::canonicalJson côté PHP ne trie pas
|
||||
// les clés, il prend l'ordre du tableau associatif. Toute modif ici doit
|
||||
// être miroir exact du dictionnaire PHP dans ValidateLicense.php.
|
||||
// Version courante (v3, depuis v0.28) : inclut settingsLockPasswordHash.
|
||||
var dict = new Dictionary<string, object?>
|
||||
{
|
||||
["status"] = response.Status,
|
||||
@@ -333,6 +349,35 @@ public sealed class LicenseService : ILicenseService
|
||||
["maxMachines"] = response.MaxMachines,
|
||||
["channel"] = response.Channel, // null si default
|
||||
["canSeeBetas"] = response.CanSeeBetas,
|
||||
["settingsLockPasswordHash"] = response.SettingsLockPasswordHash, // null si pas de lock
|
||||
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||
};
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
};
|
||||
return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical v2 (v0.26 → v0.27) : sans <c>settingsLockPasswordHash</c>.
|
||||
/// Conservé pour valider les réponses cachées par les anciennes versions
|
||||
/// du client/serveur. Une fois que toutes les licenses auront été re-validées
|
||||
/// online avec le nouveau serveur, on pourra retirer cette méthode.
|
||||
/// </summary>
|
||||
private static byte[] CanonicalBytesForV2(LicenseValidationResponse response)
|
||||
{
|
||||
var dict = new Dictionary<string, object?>
|
||||
{
|
||||
["status"] = response.Status,
|
||||
["licenseId"] = response.LicenseId,
|
||||
["ownerName"] = response.OwnerName,
|
||||
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||
["maxMachines"] = response.MaxMachines,
|
||||
["channel"] = response.Channel,
|
||||
["canSeeBetas"] = response.CanSeeBetas,
|
||||
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||
};
|
||||
var opts = new JsonSerializerOptions
|
||||
@@ -362,14 +407,18 @@ public sealed class LicenseService : ILicenseService
|
||||
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
|
||||
var sig = Convert.FromBase64String(base64Signature);
|
||||
|
||||
// Tente d'abord le canonical actuel (avec channel + canSeeBetas).
|
||||
// Si ça échoue, tente le canonical legacy (sans ces champs) — c'est
|
||||
// le format des réponses signées par les serveurs OU lancées par
|
||||
// les clients d'avant v0.26. Sans ce fallback, toutes les licenses
|
||||
// cachées localement avant l'update à v0.26 deviendraient invalides
|
||||
// et l'utilisateur serait forcé de revalider online — bloqué hors-ligne.
|
||||
var payloadCurrent = CanonicalBytesFor(response);
|
||||
if (alg.Verify(pk, payloadCurrent, sig)) return true;
|
||||
// Chaîne de fallback canonical pour compat ascendante :
|
||||
// v3 (current) : avec settingsLockPasswordHash (depuis v0.28)
|
||||
// v2 : avec channel + canSeeBetas (v0.26 → v0.27)
|
||||
// legacy : sans aucun de ces champs (avant v0.26)
|
||||
// Sans ces fallbacks, les licenses cachées par d'anciennes versions
|
||||
// deviendraient invalides et l'utilisateur serait forcé de revalider
|
||||
// online — bloqué hors-ligne.
|
||||
var payloadV3 = CanonicalBytesFor(response);
|
||||
if (alg.Verify(pk, payloadV3, sig)) return true;
|
||||
|
||||
var payloadV2 = CanonicalBytesForV2(response);
|
||||
if (alg.Verify(pk, payloadV2, sig)) return true;
|
||||
|
||||
var payloadLegacy = CanonicalBytesForLegacy(response);
|
||||
return alg.Verify(pk, payloadLegacy, sig);
|
||||
|
||||
@@ -692,6 +692,140 @@ public static class Strings
|
||||
$"⚙️ Redist {done}/{total}: {filename}"
|
||||
);
|
||||
|
||||
// ---- Mode auto (auto-launch + auto-relaunch après exit) ----
|
||||
public static string ActionAuto => T("AUTO", "AUTO", "自动", "อัตโนมัติ", "تلقائي");
|
||||
public static string AutoModeTooltipActive => T(
|
||||
"Cette version est désignée pour le lancement automatique. Clique pour désactiver.",
|
||||
"This version is set for auto-launch. Click to disable.",
|
||||
"此版本设置为自动启动。点击以禁用。",
|
||||
"เวอร์ชันนี้ถูกตั้งให้เปิดอัตโนมัติ คลิกเพื่อปิด",
|
||||
"هذه النسخة معيّنة للتشغيل التلقائي. انقر للتعطيل."
|
||||
);
|
||||
public static string AutoModeTooltipInactive => T(
|
||||
"Clique pour désigner cette version comme lancée automatiquement au démarrage du launcher.",
|
||||
"Click to set this version as auto-launched on launcher startup.",
|
||||
"点击以设置此版本在启动器启动时自动启动。",
|
||||
"คลิกเพื่อตั้งเวอร์ชันนี้ให้เปิดอัตโนมัติเมื่อตัวเปิดเริ่มต้น",
|
||||
"انقر لتعيين هذه النسخة للتشغيل التلقائي عند بدء المشغل."
|
||||
);
|
||||
|
||||
// ---- Modal de lancement auto (startup, post-exit, ou activation manuelle) ----
|
||||
public static string AutoRelaunchTitle => T("Lancement automatique", "Auto-launch", "自动启动", "เปิดอัตโนมัติ", "التشغيل التلقائي");
|
||||
public static string AutoRelaunchMessage(string version) => T(
|
||||
$"Lancement automatique de PROSERVE v{version} dans :",
|
||||
$"Auto-launching PROSERVE v{version} in:",
|
||||
$"PROSERVE v{version} 自动启动倒计时:",
|
||||
$"กำลังเปิด PROSERVE v{version} อัตโนมัติใน:",
|
||||
$"التشغيل التلقائي لـ PROSERVE v{version} خلال:"
|
||||
);
|
||||
public static string AutoRelaunchCancel => T(
|
||||
"Annuler (désactiver le mode auto)",
|
||||
"Cancel (disable auto-mode)",
|
||||
"取消(禁用自动模式)",
|
||||
"ยกเลิก (ปิดโหมดอัตโนมัติ)",
|
||||
"إلغاء (تعطيل الوضع التلقائي)"
|
||||
);
|
||||
|
||||
// ---- Phase 1 du modal auto : attente santé système (option WaitForHealthChecks) ----
|
||||
public static string AutoLaunchHealthWaitTitle => T(
|
||||
"Vérification de la santé système",
|
||||
"System health check",
|
||||
"系统健康检查",
|
||||
"ตรวจสอบสุขภาพระบบ",
|
||||
"فحص صحة النظام"
|
||||
);
|
||||
public static string AutoLaunchHealthWaitMessage => T(
|
||||
"En attente que tous les indicateurs système passent au vert avant de lancer PROSERVE…",
|
||||
"Waiting for all system indicators to turn green before launching PROSERVE…",
|
||||
"等待所有系统指标变绿后再启动 PROSERVE…",
|
||||
"รอให้ตัวบ่งชี้ทั้งหมดเป็นสีเขียวก่อนเปิด PROSERVE…",
|
||||
"في انتظار أن تصبح جميع مؤشرات النظام خضراء قبل تشغيل PROSERVE…"
|
||||
);
|
||||
public static string AutoLaunchHealthWaitPending(int count) => T(
|
||||
$"Indicateurs en attente : {count}",
|
||||
$"Pending indicators: {count}",
|
||||
$"待处理指标:{count}",
|
||||
$"ตัวบ่งชี้ที่รอ: {count}",
|
||||
$"المؤشرات المعلقة: {count}"
|
||||
);
|
||||
|
||||
// ---- Garde-fou contre double-lancement de PROSERVE ----
|
||||
public static string MsgAlreadyRunningTitle => T(
|
||||
"PROSERVE déjà lancé",
|
||||
"PROSERVE already running",
|
||||
"PROSERVE 已在运行",
|
||||
"PROSERVE กำลังทำงานอยู่แล้ว",
|
||||
"PROSERVE قيد التشغيل بالفعل"
|
||||
);
|
||||
public static string MsgAlreadyRunningBody => T(
|
||||
"Une instance de PROSERVE est déjà en cours d'exécution. Le launcher refuse de la dupliquer pour éviter les conflits (sessions, ports, fichiers verrouillés).\n\nFerme l'instance existante avant d'en lancer une autre.",
|
||||
"A PROSERVE instance is already running. The launcher refuses to start a second one to avoid conflicts (sessions, ports, locked files).\n\nClose the existing instance before starting another.",
|
||||
"PROSERVE 实例已在运行。启动器拒绝重复启动以避免冲突(会话、端口、锁定文件)。\n\n请先关闭现有实例再启动新的。",
|
||||
"มี PROSERVE ทำงานอยู่แล้ว ตัวเปิดปฏิเสธที่จะเริ่มอันที่สองเพื่อหลีกเลี่ยงความขัดแย้ง (เซสชัน, พอร์ต, ไฟล์ที่ถูกล็อก)\n\nปิดอันที่มีอยู่ก่อนเริ่มอันใหม่",
|
||||
"هناك نسخة من PROSERVE قيد التشغيل بالفعل. يرفض المشغل بدء نسخة ثانية لتجنب التعارضات (الجلسات، المنافذ، الملفات المقفلة).\n\nأغلق النسخة الحالية قبل بدء أخرى."
|
||||
);
|
||||
|
||||
// ---- Section Settings → Avancés → Mode auto ----
|
||||
public static string SettingsAutoMode => T("MODE AUTO", "AUTO MODE", "自动模式", "โหมดอัตโนมัติ", "الوضع التلقائي");
|
||||
public static string SettingsAutoModeFeatureEnabled => T(
|
||||
"Activer la fonctionnalité mode auto (affiche les boutons AUTO sur la library)",
|
||||
"Enable auto mode feature (shows AUTO buttons on the library)",
|
||||
"启用自动模式功能(在库中显示 AUTO 按钮)",
|
||||
"เปิดใช้คุณสมบัติโหมดอัตโนมัติ (แสดงปุ่ม AUTO ในไลบรารี)",
|
||||
"تمكين ميزة الوضع التلقائي (إظهار أزرار AUTO في المكتبة)"
|
||||
);
|
||||
public static string SettingsAutoModeGrace => T("Délai avant lancement (s)", "Grace period before launch (s)", "启动延迟(秒)", "ดีเลย์ก่อนเปิด (วินาที)", "الفترة قبل التشغيل (ث)");
|
||||
public static string SettingsAutoModeWaitForHealth => T(
|
||||
"Attendre que tous les indicateurs de santé système soient OK avant de lancer",
|
||||
"Wait for all system health indicators to be OK before launching",
|
||||
"在启动前等待所有系统健康指标为 OK",
|
||||
"รอให้ตัวบ่งชี้สุขภาพระบบทั้งหมดเป็น OK ก่อนเปิด",
|
||||
"انتظر حتى تكون جميع مؤشرات صحة النظام بحالة OK قبل التشغيل"
|
||||
);
|
||||
public static string SettingsAutoModeArgs => T(
|
||||
"Arguments CLI passés à PROSERVE. Saisis juste le nom (ex. \"autoconnect\", \"login\") — le launcher ajoute automatiquement les \"-\", \"=\" et les guillemets autour des valeurs avec espaces.",
|
||||
"CLI arguments passed to PROSERVE. Just type the name (e.g. \"autoconnect\", \"login\") — the launcher auto-adds \"-\", \"=\" and quotes values containing spaces.",
|
||||
"传递给 PROSERVE 的 CLI 参数。只输入名称(例如 \"autoconnect\"、\"login\"),启动器自动添加 \"-\"、\"=\" 和引号。",
|
||||
"อาร์กิวเมนต์ CLI ที่ส่งไป PROSERVE พิมพ์เฉพาะชื่อ (เช่น \"autoconnect\", \"login\") — ตัวเปิดจะเพิ่ม \"-\", \"=\" และเครื่องหมายคำพูดให้อัตโนมัติ",
|
||||
"وسائط CLI الممررة إلى PROSERVE. اكتب الاسم فقط (مثل \"autoconnect\"، \"login\") — يضيف المشغل تلقائياً \"-\" و \"=\" وعلامات الاقتباس."
|
||||
);
|
||||
public static string SettingsAutoModeArgKey => T("Clé", "Key", "键", "คีย์", "المفتاح");
|
||||
public static string SettingsAutoModeArgValue => T("Valeur (optionnel)", "Value (optional)", "值(可选)", "ค่า (ตัวเลือก)", "القيمة (اختياري)");
|
||||
public static string SettingsAutoModeArgAdd => T("+ Ajouter un argument", "+ Add argument", "+ 添加参数", "+ เพิ่มอาร์กิวเมนต์", "+ إضافة وسيطة");
|
||||
public static string SettingsAutoModeArgRemove => T("Supprimer", "Remove", "删除", "ลบ", "إزالة");
|
||||
public static string SettingsAutoModeSelected(string version) => T(
|
||||
$"Version désignée : v{version}",
|
||||
$"Selected version: v{version}",
|
||||
$"已选版本:v{version}",
|
||||
$"เวอร์ชันที่เลือก: v{version}",
|
||||
$"النسخة المحددة: v{version}"
|
||||
);
|
||||
public static string SettingsAutoModeNoneSelected => T(
|
||||
"Aucune version désignée (clique le bouton AUTO sur une version installée pour la désigner).",
|
||||
"No version selected (click the AUTO button on an installed version to set it).",
|
||||
"未选版本(点击已安装版本上的 AUTO 按钮以选择)。",
|
||||
"ยังไม่ได้เลือกเวอร์ชัน (คลิกปุ่ม AUTO บนเวอร์ชันที่ติดตั้งแล้วเพื่อเลือก)",
|
||||
"لم يتم اختيار نسخة (انقر على زر AUTO في نسخة مثبتة لتحديدها)."
|
||||
);
|
||||
|
||||
// ---- Settings lock (mot de passe pour déverrouiller la section Avancés) ----
|
||||
public static string SettingsLockedTitle => T("Paramètres avancés verrouillés", "Advanced settings locked", "高级设置已锁定", "การตั้งค่าขั้นสูงถูกล็อก", "الإعدادات المتقدمة مقفلة");
|
||||
public static string SettingsLockedBody => T(
|
||||
"Les paramètres avancés sont protégés par un mot de passe défini par l'administrateur. Saisis-le pour les déverrouiller (déverrouillage valable jusqu'à la fermeture du launcher).",
|
||||
"Advanced settings are protected by a password set by the administrator. Enter it to unlock (unlock persists until launcher restart).",
|
||||
"高级设置受管理员设置的密码保护。输入密码以解锁(解锁状态保持到启动器重新启动)。",
|
||||
"การตั้งค่าขั้นสูงถูกป้องกันด้วยรหัสผ่านที่ผู้ดูแลกำหนด ใส่รหัสเพื่อปลดล็อก (การปลดล็อกคงอยู่จนกว่าจะรีสตาร์ทตัวเปิด)",
|
||||
"الإعدادات المتقدمة محمية بكلمة مرور حددها المسؤول. أدخلها لإلغاء القفل (إلغاء القفل يستمر حتى إعادة تشغيل المشغل)."
|
||||
);
|
||||
public static string SettingsLockedUnlock => T("Déverrouiller", "Unlock", "解锁", "ปลดล็อก", "إلغاء القفل");
|
||||
public static string SettingsLockedWrongPassword => T(
|
||||
"Mot de passe incorrect.",
|
||||
"Incorrect password.",
|
||||
"密码错误。",
|
||||
"รหัสผ่านไม่ถูกต้อง",
|
||||
"كلمة المرور غير صحيحة."
|
||||
);
|
||||
|
||||
// ---- Badge source du manifest (origine de la liste de versions affichée) ----
|
||||
public static string ManifestSourceOvh => T("🌐 OVH (en ligne)", "🌐 OVH (online)", "🌐 OVH(在线)", "🌐 OVH (ออนไลน์)", "🌐 OVH (متصل)");
|
||||
public static string ManifestSourcePeer => T("📡 Peer LAN (hors ligne)", "📡 LAN peer (offline)", "📡 局域网 Peer(离线)", "📡 LAN Peer (ออฟไลน์)", "📡 LAN Peer (غير متصل)");
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PSLauncher.Core.Lan;
|
||||
using PSLauncher.Core.Security;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Manifests;
|
||||
@@ -20,6 +21,7 @@ public sealed class ManifestService : IManifestService
|
||||
private readonly Func<string?> _channelProvider;
|
||||
private readonly IManifestCache _manifestCache;
|
||||
private readonly IPeerManifestFetcher _peerManifestFetcher;
|
||||
private readonly ISettingsLockService _settingsLock;
|
||||
private readonly ILogger<ManifestService> _logger;
|
||||
|
||||
public ManifestSource LastSource { get; private set; } = ManifestSource.None;
|
||||
@@ -30,6 +32,7 @@ public sealed class ManifestService : IManifestService
|
||||
Func<string?> channelProvider,
|
||||
IManifestCache manifestCache,
|
||||
IPeerManifestFetcher peerManifestFetcher,
|
||||
ISettingsLockService settingsLock,
|
||||
ILogger<ManifestService> logger)
|
||||
{
|
||||
_http = http;
|
||||
@@ -37,9 +40,23 @@ public sealed class ManifestService : IManifestService
|
||||
_channelProvider = channelProvider;
|
||||
_manifestCache = manifestCache;
|
||||
_peerManifestFetcher = peerManifestFetcher;
|
||||
_settingsLock = settingsLock;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op depuis v0.28 : le hash settings-lock vient maintenant de la
|
||||
/// <c>LicenseValidationResponse</c> (per-license). Voir
|
||||
/// <see cref="PSLauncher.Core.Licensing.LicenseService.ValidateAsync"/> et
|
||||
/// <see cref="PSLauncher.Core.Licensing.LicenseService.GetCached"/> pour la
|
||||
/// propagation effective vers <see cref="ISettingsLockService"/>.
|
||||
/// </summary>
|
||||
private void PropagateSettingsLock(RemoteManifest? manifest)
|
||||
{
|
||||
_ = manifest;
|
||||
_ = _settingsLock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Timeout court sur la tentative OVH : si le serveur Internet ne répond pas
|
||||
/// rapidement, on bascule sur les fallbacks LAN/cache plutôt que d'attendre les
|
||||
@@ -76,6 +93,7 @@ public sealed class ManifestService : IManifestService
|
||||
?? throw new InvalidOperationException("Empty manifest");
|
||||
await _manifestCache.SaveAsync(rawJson, ct).ConfigureAwait(false);
|
||||
LastSource = ManifestSource.Ovh;
|
||||
PropagateSettingsLock(manifest);
|
||||
return manifest;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||
@@ -86,6 +104,7 @@ public sealed class ManifestService : IManifestService
|
||||
if (cached is not null)
|
||||
{
|
||||
LastSource = ManifestSource.DiskCache;
|
||||
PropagateSettingsLock(cached);
|
||||
return cached;
|
||||
}
|
||||
throw;
|
||||
@@ -102,6 +121,7 @@ public sealed class ManifestService : IManifestService
|
||||
// Cache aussi le peer manifest pour servir aux autres clients du LAN.
|
||||
await _manifestCache.SaveAsync(peerJson, ct).ConfigureAwait(false);
|
||||
LastSource = ManifestSource.Peer;
|
||||
PropagateSettingsLock(manifest);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@@ -111,6 +131,7 @@ public sealed class ManifestService : IManifestService
|
||||
{
|
||||
_logger.LogInformation("Manifest récupéré depuis le cache disque (offline + pas de peer)");
|
||||
LastSource = ManifestSource.DiskCache;
|
||||
PropagateSettingsLock(localCache);
|
||||
return localCache;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,32 @@ public sealed class ProcessLauncher : IProcessLauncher
|
||||
{
|
||||
FileName = version.ExecutablePath,
|
||||
WorkingDirectory = version.FolderPath,
|
||||
UseShellExecute = true
|
||||
UseShellExecute = true,
|
||||
};
|
||||
if (args is not null)
|
||||
|
||||
// Construction MANUELLE de la ligne de commande (via Arguments string),
|
||||
// PAS via psi.ArgumentList. Pourquoi : avec UseShellExecute=true, .NET
|
||||
// re-sérialise ArgumentList en interne en appliquant un quotage Win32
|
||||
// auto qui peut insérer des guillemets inattendus autour des args
|
||||
// contenant `=` (cf. bug rapporté : `-login=Jerome` arrivait à Unreal
|
||||
// sous une forme que FParse::Value ne reconnaissait pas).
|
||||
//
|
||||
// En passant Arguments directement, on contrôle au caractère près
|
||||
// la ligne de commande effective — c'est la même chaîne qu'aurait
|
||||
// produite un .bat avec les mêmes paramètres. Le caller (typiquement
|
||||
// `MainViewModel.BuildAutoModeCliArgs`) est responsable du quotage
|
||||
// approprié des valeurs avec espaces.
|
||||
if (args is not null && args.Length > 0)
|
||||
{
|
||||
foreach (var arg in args) psi.ArgumentList.Add(arg);
|
||||
psi.Arguments = string.Join(" ", args);
|
||||
_logger.LogInformation("Launching {Exe} with args: {Args}",
|
||||
version.ExecutablePath, psi.Arguments);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Launching {Exe}", version.ExecutablePath);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Launching {Exe}", version.ExecutablePath);
|
||||
return SysProcess.Start(psi)
|
||||
?? throw new InvalidOperationException("Process.Start returned null");
|
||||
}
|
||||
|
||||
30
src/PSLauncher.Core/Security/ISettingsLockService.cs
Normal file
30
src/PSLauncher.Core/Security/ISettingsLockService.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace PSLauncher.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Gestion du verrouillage par mot de passe de la section Avancés des Settings.
|
||||
///
|
||||
/// Le hash du password vient du manifest signé (<see cref="PSLauncher.Models.RemoteManifest.SettingsLockPasswordHash"/>) :
|
||||
/// administré centralement depuis le backoffice, intégrité garantie par la signature
|
||||
/// Ed25519. Quand le hash est non-null/non-vide, les paramètres avancés sont gated
|
||||
/// derrière un dialog de saisie de password.
|
||||
///
|
||||
/// L'unlock est valable pour la session courante du launcher uniquement
|
||||
/// (pas persisté sur disque). Au redémarrage, l'utilisateur doit re-saisir.
|
||||
/// </summary>
|
||||
public interface ISettingsLockService
|
||||
{
|
||||
/// <summary>True s'il y a un hash défini dans le manifest courant ET qu'il n'a pas été déverrouillé cette session.</summary>
|
||||
bool IsLocked { get; }
|
||||
|
||||
/// <summary>True s'il y a un hash défini (lock configuré). Si false, pas de lock, accès libre.</summary>
|
||||
bool HasLockConfigured { get; }
|
||||
|
||||
/// <summary>Tente de déverrouiller avec un password. True si match (déverrouillage actif), false sinon.</summary>
|
||||
bool TryUnlock(string password);
|
||||
|
||||
/// <summary>Force le re-verrouillage (par exemple à la fermeture du dialog Settings).</summary>
|
||||
void Lock();
|
||||
|
||||
/// <summary>Met à jour le hash du password (appelé après chaque fetch manifest réussi).</summary>
|
||||
void SetPasswordHash(string? hash);
|
||||
}
|
||||
47
src/PSLauncher.Core/Security/SettingsLockService.cs
Normal file
47
src/PSLauncher.Core/Security/SettingsLockService.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace PSLauncher.Core.Security;
|
||||
|
||||
public sealed class SettingsLockService : ISettingsLockService
|
||||
{
|
||||
private string? _expectedHashHex;
|
||||
private bool _unlocked;
|
||||
|
||||
public bool HasLockConfigured =>
|
||||
!string.IsNullOrWhiteSpace(_expectedHashHex);
|
||||
|
||||
public bool IsLocked => HasLockConfigured && !_unlocked;
|
||||
|
||||
public bool TryUnlock(string password)
|
||||
{
|
||||
if (!HasLockConfigured)
|
||||
{
|
||||
_unlocked = true;
|
||||
return true;
|
||||
}
|
||||
if (string.IsNullOrEmpty(password)) return false;
|
||||
var inputHash = HashHex(password);
|
||||
var match = string.Equals(inputHash, _expectedHashHex, StringComparison.OrdinalIgnoreCase);
|
||||
if (match) _unlocked = true;
|
||||
return match;
|
||||
}
|
||||
|
||||
public void Lock() => _unlocked = false;
|
||||
|
||||
public void SetPasswordHash(string? hash)
|
||||
{
|
||||
_expectedHashHex = string.IsNullOrWhiteSpace(hash) ? null : hash.Trim();
|
||||
// Si le hash change (admin a modifié le password backoffice), on re-verrouille.
|
||||
_unlocked = false;
|
||||
}
|
||||
|
||||
/// <summary>SHA-256 hex lowercase d'une string UTF-8. Format attendu par le backoffice.</summary>
|
||||
public static string HashHex(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
var sb = new StringBuilder(bytes.Length * 2);
|
||||
foreach (var b in bytes) sb.Append(b.ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,15 @@ public sealed class LicenseValidationResponse
|
||||
[JsonPropertyName("canSeeBetas")]
|
||||
public bool CanSeeBetas { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash SHA-256 (hex lowercase) du mot de passe qui verrouille les Settings
|
||||
/// avancés du launcher. NULL = pas de verrouillage. Défini par-license dans
|
||||
/// le backoffice (table <c>licenses.settings_lock_password_hash</c>) → permet
|
||||
/// un mot de passe différent par client. Inclus dans la signature Ed25519.
|
||||
/// </summary>
|
||||
[JsonPropertyName("settingsLockPasswordHash")]
|
||||
public string? SettingsLockPasswordHash { get; set; }
|
||||
|
||||
[JsonPropertyName("serverTime")]
|
||||
public DateTime? ServerTime { get; set; }
|
||||
|
||||
|
||||
@@ -80,6 +80,13 @@ public sealed class LocalConfig
|
||||
/// </summary>
|
||||
public LanCacheConfig LanCache { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Mode auto : lance automatiquement une version sélectionnée au démarrage du
|
||||
/// launcher, et relance si PROSERVE quitte (avec un grace period 3-5s pendant
|
||||
/// lequel l'opérateur peut annuler pour reprendre la main).
|
||||
/// </summary>
|
||||
public AutoModeConfig AutoMode { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Surveillance d'état système affichée en bandeau sous la top bar
|
||||
/// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…).
|
||||
@@ -308,6 +315,63 @@ public sealed class HealthCheckEntry
|
||||
public int? PingTimeoutMs { get; set; } = 1000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration du mode auto. <see cref="FeatureEnabled"/> est le kill-switch
|
||||
/// global — quand false, les boutons AUTO ne sont pas affichés sur les rows et
|
||||
/// le launcher ne fait jamais d'auto-launch. Quand true, l'opérateur peut
|
||||
/// désigner UNE version via le bouton AUTO ; le launcher lance cette version
|
||||
/// au démarrage et la relance si elle quitte (après un grace period).
|
||||
/// </summary>
|
||||
public sealed class AutoModeConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Master toggle : si false, le mode auto est désactivé partout (boutons AUTO
|
||||
/// masqués, pas d'auto-launch). Réglable dans Settings → Avancés.
|
||||
/// </summary>
|
||||
public bool FeatureEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Version courante désignée pour l'auto-launch (le bouton AUTO orange sur cette
|
||||
/// row). Null = aucune désignée (mode auto inactif même si FeatureEnabled=true).
|
||||
/// </summary>
|
||||
public string? SelectedVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Délai en secondes affiché dans le popup modal de relance après que PROSERVE
|
||||
/// ait quitté. Pendant ce délai l'opérateur peut cliquer Annuler pour reprendre
|
||||
/// la main. Clamped 3-10s côté UI.
|
||||
/// </summary>
|
||||
public int GracePeriodSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Arguments CLI passés à PROSERVE_UE_*.exe. Format produit :
|
||||
/// si <c>Value</c> est null/vide : <c>-{Key}</c> (flag seul)
|
||||
/// sinon : <c>-{Key}={Value}</c>
|
||||
/// Exemples : <c>-autoconnect</c>, <c>-login=Jerome2</c>, <c>-ipserver=10.0.4.100</c>
|
||||
/// </summary>
|
||||
public List<AutoModeArg> Args { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Si true, le launcher attend que TOUS les indicateurs de santé système
|
||||
/// (<see cref="LocalConfig.HealthChecks"/>) soient en <c>HealthSeverity.Ok</c>
|
||||
/// avant de démarrer le countdown de lancement automatique. Utile quand
|
||||
/// PROSERVE dépend d'un serveur API, d'un MySQL local, ou de SteamVR qui
|
||||
/// peut mettre quelques secondes à se stabiliser au boot. Pendant l'attente,
|
||||
/// l'opérateur voit un dialog "Vérification de la santé système" avec un
|
||||
/// bouton Annuler ; le countdown ne démarre qu'une fois tous les indicateurs
|
||||
/// au vert. Si la liste de health checks est vide, ce flag n'a aucun effet
|
||||
/// (rien à attendre → countdown immédiat).
|
||||
/// </summary>
|
||||
public bool WaitForHealthChecks { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>Une paire clé/valeur d'argument CLI pour PROSERVE. Value optionnel.</summary>
|
||||
public sealed class AutoModeArg
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LicenseConfig
|
||||
{
|
||||
public string? EncryptedKey { get; set; }
|
||||
@@ -338,6 +402,14 @@ public sealed class LicenseConfig
|
||||
/// </summary>
|
||||
public bool CachedCanSeeBetas { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hash SHA-256 du password de verrouillage des Settings avancés associé à
|
||||
/// cette license (NULL = pas de verrouillage). Persisté entre les lancements
|
||||
/// pour que le verrouillage reste actif même hors-ligne, jusqu'à expiration
|
||||
/// du cache offline (100 jours).
|
||||
/// </summary>
|
||||
public string? CachedSettingsLockPasswordHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Statut tel que renvoyé par le serveur lors de la dernière validation : "valid",
|
||||
/// "expired", "revoked", "invalid", "machine_limit_exceeded". Persisté pour qu'une
|
||||
|
||||
355
version/proserve-1.5.4/_migrations/0001_Init.sql
Normal file
355
version/proserve-1.5.4/_migrations/0001_Init.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user