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 MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PS_Launcher"
|
#define MyAppShortName "PS_Launcher"
|
||||||
#define MyAppVersion "0.27.4"
|
#define MyAppVersion "0.28.10"
|
||||||
#define MyAppPublisher "ASTERION VR"
|
#define MyAppPublisher "ASTERION VR"
|
||||||
#define MyAppURL "https://asterionvr.com"
|
#define MyAppURL "https://asterionvr.com"
|
||||||
#define MyAppExeName "PS_Launcher.exe"
|
#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']);
|
$message = "Sortie du script (scope: launcher{$forceLabel}) :\n" . implode("\n", $result['log']);
|
||||||
if (!$result['ok']) $messageType = 'error';
|
if (!$result['ok']) $messageType = 'error';
|
||||||
}
|
}
|
||||||
|
// (Le settings lock est maintenant par-license, géré dans licenses.php — voir migration 003.)
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$message = $e->getMessage();
|
$message = $e->getMessage();
|
||||||
$messageType = 'error';
|
$messageType = 'error';
|
||||||
|
|||||||
@@ -145,6 +145,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$db->prepare('UPDATE licenses SET max_machines = ? WHERE id = ?')->execute([$newMax, $id]);
|
$db->prepare('UPDATE licenses SET max_machines = ? WHERE id = ?')->execute([$newMax, $id]);
|
||||||
$message = "License #{$id} : limite passée à {$newMax} machine(s).";
|
$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') {
|
elseif ($action === 'reset_machines') {
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
|
$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>
|
<button class="btn btn-primary" type="submit">OK</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</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): ?>
|
<?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.')">
|
<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() ?>
|
<?= Layout::csrfField() ?>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final class ValidateLicense
|
|||||||
|
|
||||||
$stmt = $db->prepare(
|
$stmt = $db->prepare(
|
||||||
'SELECT id, license_key, owner_name, issued_at, download_entitlement_until,
|
'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'
|
FROM licenses WHERE license_key = ? LIMIT 1'
|
||||||
);
|
);
|
||||||
$stmt->execute([$licenseKey]);
|
$stmt->execute([$licenseKey]);
|
||||||
@@ -92,6 +92,12 @@ final class ValidateLicense
|
|||||||
? (string)$lic['channel']
|
? (string)$lic['channel']
|
||||||
: null;
|
: null;
|
||||||
$canSeeBetas = (bool)($lic['can_see_betas'] ?? 0);
|
$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 = [
|
$payload = [
|
||||||
'status' => $expired ? 'expired' : 'valid',
|
'status' => $expired ? 'expired' : 'valid',
|
||||||
@@ -102,6 +108,7 @@ final class ValidateLicense
|
|||||||
'maxMachines' => (int)$lic['max_machines'],
|
'maxMachines' => (int)$lic['max_machines'],
|
||||||
'channel' => $channel,
|
'channel' => $channel,
|
||||||
'canSeeBetas' => $canSeeBetas,
|
'canSeeBetas' => $canSeeBetas,
|
||||||
|
'settingsLockPasswordHash' => $settingsLockHash,
|
||||||
'serverTime' => $serverTime,
|
'serverTime' => $serverTime,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,45 +5,14 @@
|
|||||||
--
|
--
|
||||||
-- À jouer après 001_init.sql.
|
-- À jouer après 001_init.sql.
|
||||||
--
|
--
|
||||||
-- IDEMPOTENCE : OVH mutualisé peut tourner sur MariaDB pré-10.0.2 où
|
-- IDEMPOTENCE : les ALTER ci-dessous throw une exception sur OVH si la colonne /
|
||||||
-- ALTER TABLE ... IF NOT EXISTS n'existe pas. On contourne avec une PROCEDURE
|
-- l'index existe déjà. migrate.php attrape ces erreurs spécifiques ("Duplicate
|
||||||
-- temporaire qui consulte INFORMATION_SCHEMA avant chaque ALTER. Comme ça la
|
-- column", "Duplicate key name") et continue. Tu peux rejouer le script sans
|
||||||
-- migration peut être re-jouée sans erreur sur n'importe quelle version
|
-- risque sur n'importe quelle DB (vide, partiellement migrée, complètement).
|
||||||
-- MariaDB / MySQL 5.5+.
|
|
||||||
|
|
||||||
DROP PROCEDURE IF EXISTS 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;
|
||||||
DELIMITER $$
|
ALTER TABLE licenses ADD INDEX idx_channel (channel);
|
||||||
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;
|
|
||||||
|
|
||||||
-- Note : pas de migration de data. Les licenses existantes gardent
|
-- 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).
|
-- 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);
|
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);
|
||||||
out(" ✔ " . $first40 . "…");
|
out(" ✔ " . $first40 . "…");
|
||||||
} catch (Exception $e) {
|
} 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) . "…");
|
out(" Sur : " . substr($stmt, 0, 80) . "…");
|
||||||
exit(3);
|
exit(3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ using PSLauncher.Core.Integrity;
|
|||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
using PSLauncher.Core.Localization;
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Core.Manifests;
|
using PSLauncher.Core.Manifests;
|
||||||
|
using PSLauncher.Core.Security;
|
||||||
using PSLauncher.Core.ApiTool;
|
using PSLauncher.Core.ApiTool;
|
||||||
using PSLauncher.Core.DocTool;
|
using PSLauncher.Core.DocTool;
|
||||||
using PSLauncher.Core.Lan;
|
using PSLauncher.Core.Lan;
|
||||||
@@ -33,6 +34,13 @@ namespace PSLauncher.App;
|
|||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
private IHost? _host;
|
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 static System.Threading.Mutex? _singleInstanceMutex;
|
||||||
|
|
||||||
private const string SingleInstanceMutexName = "Global\\PSLauncher-3F8E2C1A-9B47-4D5E-A0F8-2E9D1B6C7A3F";
|
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<IDownloadStateStore, DownloadStateStore>();
|
||||||
|
|
||||||
services.AddSingleton<IManifestCache, ManifestCache>();
|
services.AddSingleton<IManifestCache, ManifestCache>();
|
||||||
|
services.AddSingleton<ISettingsLockService, SettingsLockService>();
|
||||||
services.AddSingleton<IManifestService>(sp =>
|
services.AddSingleton<IManifestService>(sp =>
|
||||||
new ManifestService(
|
new ManifestService(
|
||||||
sp.GetRequiredService<HttpClient>(),
|
sp.GetRequiredService<HttpClient>(),
|
||||||
@@ -186,6 +195,7 @@ public partial class App : Application
|
|||||||
() => sp.GetRequiredService<ILicenseService>().GetCached()?.Channel,
|
() => sp.GetRequiredService<ILicenseService>().GetCached()?.Channel,
|
||||||
sp.GetRequiredService<IManifestCache>(),
|
sp.GetRequiredService<IManifestCache>(),
|
||||||
sp.GetRequiredService<IPeerManifestFetcher>(),
|
sp.GetRequiredService<IPeerManifestFetcher>(),
|
||||||
|
sp.GetRequiredService<ISettingsLockService>(),
|
||||||
sp.GetRequiredService<ILogger<ManifestService>>()));
|
sp.GetRequiredService<ILogger<ManifestService>>()));
|
||||||
|
|
||||||
services.AddSingleton<IDownloadManager, DownloadManager>();
|
services.AddSingleton<IDownloadManager, DownloadManager>();
|
||||||
@@ -198,6 +208,7 @@ public partial class App : Application
|
|||||||
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
||||||
sp.GetRequiredService<IConfigStore>(),
|
sp.GetRequiredService<IConfigStore>(),
|
||||||
sp.GetRequiredService<LocalConfig>(),
|
sp.GetRequiredService<LocalConfig>(),
|
||||||
|
sp.GetRequiredService<ISettingsLockService>(),
|
||||||
sp.GetRequiredService<ILogger<LicenseService>>()));
|
sp.GetRequiredService<ILogger<LicenseService>>()));
|
||||||
|
|
||||||
services.AddSingleton<IToastService, ToastService>();
|
services.AddSingleton<IToastService, ToastService>();
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
<Product>PROSERVE Launcher</Product>
|
<Product>PROSERVE Launcher</Product>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||||
<Version>0.27.4</Version>
|
<Version>0.28.10</Version>
|
||||||
<AssemblyVersion>0.27.4.0</AssemblyVersion>
|
<AssemblyVersion>0.28.10.0</AssemblyVersion>
|
||||||
<FileVersion>0.27.4.0</FileVersion>
|
<FileVersion>0.28.10.0</FileVersion>
|
||||||
|
|
||||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private Task? _activeInstallTask;
|
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();
|
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -430,6 +441,14 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
},
|
},
|
||||||
Application.Current.Dispatcher);
|
Application.Current.Dispatcher);
|
||||||
_lanInfoRefreshTimer.Start();
|
_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>
|
/// <summary>
|
||||||
@@ -607,6 +626,72 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
|
||||||
row.OpenFolderHandler = OpenVersionFolder;
|
row.OpenFolderHandler = OpenVersionFolder;
|
||||||
row.RestartFromZeroHandler = r => _ = RestartFromZeroAsync(r);
|
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>
|
/// <summary>
|
||||||
@@ -897,9 +982,48 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private void LaunchVersion(VersionRowViewModel row)
|
private void LaunchVersion(VersionRowViewModel row)
|
||||||
{
|
{
|
||||||
if (row.Installed is null) return;
|
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
|
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;
|
_config.LastLaunchedVersion = row.Version;
|
||||||
_configStore.Save(_config);
|
_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);
|
_logger.LogInformation("PROSERVE v{Version} a quitté — restauration du launcher", row.Version);
|
||||||
Application.Current?.Dispatcher.Invoke(() =>
|
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 (Application.Current.MainWindow is { } main)
|
||||||
{
|
{
|
||||||
if (main.WindowState == WindowState.Minimized)
|
if (main.WindowState == WindowState.Minimized)
|
||||||
@@ -926,23 +1057,252 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
main.Topmost = true;
|
main.Topmost = true;
|
||||||
main.Topmost = false;
|
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)
|
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");
|
_logger.LogDebug(ex, "Could not hook Exited on launched process");
|
||||||
|
_runningProserve = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Launch failed");
|
_logger.LogError(ex, "Launch failed");
|
||||||
|
_runningProserve = null;
|
||||||
ThemedMessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
|
ThemedMessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
|
||||||
Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error);
|
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)
|
private async Task InstallVersionAsync(VersionRowViewModel row)
|
||||||
{
|
{
|
||||||
if (row.Remote is null) return;
|
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.
|
// 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);
|
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
|
// 4) Install
|
||||||
row.State = VersionRowState.Installing;
|
row.State = VersionRowState.Installing;
|
||||||
StatusMessage = Strings.StatusInstallingVersion(row.Version);
|
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.
|
// installer. Track via metadata pour ne pas re-runner à chaque réinstall.
|
||||||
// Si un installer fail (already-installed retournent souvent un exit code
|
// 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
|
// non-zero), on log mais on continue — l'install PROSERVE ne doit pas être
|
||||||
// bloquée par un redist mineur.
|
// 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);
|
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
|
// Prune les vieilles versions selon MaxCachedVersions (le Promote a déjà
|
||||||
// versions selon MaxCachedVersions. Le ZIP est déjà au bon endroit
|
// eu lieu juste après le DL, voir bloc plus haut). On prune ici post-install
|
||||||
// (DownloadManager.VerifyAndFinalizeAsync l'a Move'd vers `proserve-X.zip`),
|
// pour que les versions encore installées soient protégées (la version qu'on
|
||||||
// donc PromoteAsync est essentiellement un no-op + écriture du sidecar.
|
// vient d'installer compte comme "encore installée").
|
||||||
// Les versions encore installées sont protégées du pruning.
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _zipCacheStore.PromoteAsync(zipPath, row.Version, row.Remote.Download.Sha256, ct);
|
|
||||||
await _zipCacheStore.PruneAsync(_config.LanCache.MaxCachedVersions, ct);
|
await _zipCacheStore.PruneAsync(_config.LanCache.MaxCachedVersions, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
// 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);
|
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
||||||
ProgressDetail = null;
|
ProgressDetail = null;
|
||||||
ProgressPercent = 0;
|
ProgressPercent = 0;
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new();
|
public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new();
|
||||||
public bool HasLanDiscoveredPeers => LanDiscoveredPeers.Count > 0;
|
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) -----
|
// ----- Health checks (bandeau de santé système) -----
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne
|
/// 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;
|
_apiAutoDeploy = config.ApiTool.AutoDeploy;
|
||||||
_apiMaxBackups = config.ApiTool.MaxBackups;
|
_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;
|
_lanServerEnabled = config.LanCache.ServerEnabled;
|
||||||
_lanClientEnabled = config.LanCache.ClientEnabled;
|
_lanClientEnabled = config.LanCache.ClientEnabled;
|
||||||
_lanServerPort = config.LanCache.ServerPort;
|
_lanServerPort = config.LanCache.ServerPort;
|
||||||
@@ -413,6 +429,20 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
|
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
|
||||||
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);
|
_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.ServerEnabled = LanServerEnabled;
|
||||||
_config.LanCache.ClientEnabled = LanClientEnabled;
|
_config.LanCache.ClientEnabled = LanClientEnabled;
|
||||||
_config.LanCache.ServerPort = LanServerPort > 0 && LanServerPort <= 65535 ? LanServerPort : 47623;
|
_config.LanCache.ServerPort = LanServerPort > 0 && LanServerPort <= 65535 ? LanServerPort : 47623;
|
||||||
@@ -990,6 +1020,42 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
IsDeployingApi = false;
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -71,6 +71,44 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
|
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
|
||||||
private bool _licenseAllowsDownload = true;
|
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 HasResumableDownload => ResumableBytes > 0;
|
||||||
public bool LicenseAllowsInstall => LicenseAllowsDownload;
|
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 -->
|
<!-- Action button -->
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0">
|
<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}"
|
<Button Style="{StaticResource PrimaryButton}"
|
||||||
Content="{x:Static loc:Strings.ActionLaunch}" Padding="22,8" FontSize="13"
|
Content="{x:Static loc:Strings.ActionLaunch}" Padding="22,8" FontSize="13"
|
||||||
Command="{Binding LaunchCommand}"
|
Command="{Binding LaunchCommand}"
|
||||||
@@ -527,13 +548,34 @@
|
|||||||
</TextBlock>
|
</TextBlock>
|
||||||
|
|
||||||
<!-- Action principale (gros bouton) -->
|
<!-- Action principale (gros bouton) -->
|
||||||
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
||||||
Style="{StaticResource PrimaryButton}"
|
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}"
|
Content="{x:Static loc:Strings.ActionLaunchBig}"
|
||||||
FontSize="20" Padding="56,16"
|
FontSize="20" Padding="56,16"
|
||||||
VerticalAlignment="Center"
|
Command="{Binding FeaturedVersion.LaunchCommand}" />
|
||||||
Command="{Binding FeaturedVersion.LaunchCommand}"
|
</StackPanel>
|
||||||
Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}" />
|
|
||||||
|
|
||||||
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
||||||
Orientation="Vertical" VerticalAlignment="Center"
|
Orientation="Vertical" VerticalAlignment="Center"
|
||||||
|
|||||||
@@ -174,11 +174,13 @@
|
|||||||
importantes (License, Langue, About) qui restent dépliées.
|
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.
|
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}"
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
FontSize="11" FontWeight="Bold"
|
FontSize="11" FontWeight="Bold"
|
||||||
Background="Transparent" BorderThickness="0"
|
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">
|
<StackPanel Margin="0,12,0,0">
|
||||||
|
|
||||||
<!-- Serveur -->
|
<!-- Serveur -->
|
||||||
@@ -648,6 +650,94 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</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) -->
|
<!-- Cache LAN P2P (peers du LAN partagent les ZIPs entre eux) -->
|
||||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PSLauncher.App.ViewModels;
|
using PSLauncher.App.ViewModels;
|
||||||
|
using PSLauncher.Core.Security;
|
||||||
|
|
||||||
namespace PSLauncher.App.Views;
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
@@ -25,4 +30,80 @@ public partial class SettingsDialog : Window
|
|||||||
DialogResult = false;
|
DialogResult = false;
|
||||||
Close();
|
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();
|
var sha = File.ReadAllText(shaPath, Encoding.ASCII).Trim();
|
||||||
if (sha.Length != 64) return false; // SHA-256 hex = 64 chars
|
if (sha.Length != 64) return false; // SHA-256 hex = 64 chars
|
||||||
var fi = new FileInfo(zipPath);
|
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);
|
entry = new CachedZip(version, zipPath, fi.Length, sha, fi.LastWriteTimeUtc);
|
||||||
return true;
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Cache lookup failed for {Version}", version);
|
_logger.LogDebug(ex, "Cache lookup failed for {Version}", version);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using System.Text.Json.Serialization;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NSec.Cryptography;
|
using NSec.Cryptography;
|
||||||
using PSLauncher.Core.Configuration;
|
using PSLauncher.Core.Configuration;
|
||||||
|
using PSLauncher.Core.Security;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.Core.Licensing;
|
namespace PSLauncher.Core.Licensing;
|
||||||
@@ -36,6 +37,7 @@ public sealed class LicenseService : ILicenseService
|
|||||||
private readonly Func<string> _serverBaseUrlProvider;
|
private readonly Func<string> _serverBaseUrlProvider;
|
||||||
private readonly IConfigStore _configStore;
|
private readonly IConfigStore _configStore;
|
||||||
private readonly LocalConfig _config;
|
private readonly LocalConfig _config;
|
||||||
|
private readonly ISettingsLockService _settingsLock;
|
||||||
private readonly ILogger<LicenseService> _logger;
|
private readonly ILogger<LicenseService> _logger;
|
||||||
private readonly string? _serverPublicKeyHex;
|
private readonly string? _serverPublicKeyHex;
|
||||||
|
|
||||||
@@ -44,10 +46,12 @@ public sealed class LicenseService : ILicenseService
|
|||||||
Func<string> serverBaseUrlProvider,
|
Func<string> serverBaseUrlProvider,
|
||||||
IConfigStore configStore,
|
IConfigStore configStore,
|
||||||
LocalConfig config,
|
LocalConfig config,
|
||||||
|
ISettingsLockService settingsLock,
|
||||||
ILogger<LicenseService> logger)
|
ILogger<LicenseService> logger)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_serverBaseUrlProvider = serverBaseUrlProvider;
|
_serverBaseUrlProvider = serverBaseUrlProvider;
|
||||||
|
_settingsLock = settingsLock;
|
||||||
_configStore = configStore;
|
_configStore = configStore;
|
||||||
_config = config;
|
_config = config;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -130,6 +134,10 @@ public sealed class LicenseService : ILicenseService
|
|||||||
_logger.LogDebug("License response signature OK");
|
_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;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +214,7 @@ public sealed class LicenseService : ILicenseService
|
|||||||
cachedStatus = "expired";
|
cachedStatus = "expired";
|
||||||
}
|
}
|
||||||
|
|
||||||
return new LicenseValidationResponse
|
var cached = new LicenseValidationResponse
|
||||||
{
|
{
|
||||||
Status = cachedStatus,
|
Status = cachedStatus,
|
||||||
OwnerName = _config.License.CachedOwnerName,
|
OwnerName = _config.License.CachedOwnerName,
|
||||||
@@ -219,7 +227,13 @@ public sealed class LicenseService : ILicenseService
|
|||||||
// jamais les builds spécifiques à son channel (firefighter, police…).
|
// jamais les builds spécifiques à son channel (firefighter, police…).
|
||||||
Channel = _config.License.CachedChannel,
|
Channel = _config.License.CachedChannel,
|
||||||
CanSeeBetas = _config.License.CachedCanSeeBetas,
|
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)
|
public void SaveCached(string licenseKey, LicenseValidationResponse response)
|
||||||
@@ -236,6 +250,7 @@ public sealed class LicenseService : ILicenseService
|
|||||||
_config.License.CachedStatus = response.Status;
|
_config.License.CachedStatus = response.Status;
|
||||||
_config.License.CachedChannel = response.Channel;
|
_config.License.CachedChannel = response.Channel;
|
||||||
_config.License.CachedCanSeeBetas = response.CanSeeBetas;
|
_config.License.CachedCanSeeBetas = response.CanSeeBetas;
|
||||||
|
_config.License.CachedSettingsLockPasswordHash = response.SettingsLockPasswordHash;
|
||||||
_configStore.Save(_config);
|
_configStore.Save(_config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +338,7 @@ public sealed class LicenseService : ILicenseService
|
|||||||
// ATTENTION : ordre CRITIQUE — Crypto::canonicalJson côté PHP ne trie pas
|
// 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
|
// les clés, il prend l'ordre du tableau associatif. Toute modif ici doit
|
||||||
// être miroir exact du dictionnaire PHP dans ValidateLicense.php.
|
// être miroir exact du dictionnaire PHP dans ValidateLicense.php.
|
||||||
|
// Version courante (v3, depuis v0.28) : inclut settingsLockPasswordHash.
|
||||||
var dict = new Dictionary<string, object?>
|
var dict = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["status"] = response.Status,
|
["status"] = response.Status,
|
||||||
@@ -333,6 +349,35 @@ public sealed class LicenseService : ILicenseService
|
|||||||
["maxMachines"] = response.MaxMachines,
|
["maxMachines"] = response.MaxMachines,
|
||||||
["channel"] = response.Channel, // null si default
|
["channel"] = response.Channel, // null si default
|
||||||
["canSeeBetas"] = response.CanSeeBetas,
|
["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),
|
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||||
};
|
};
|
||||||
var opts = new JsonSerializerOptions
|
var opts = new JsonSerializerOptions
|
||||||
@@ -362,14 +407,18 @@ public sealed class LicenseService : ILicenseService
|
|||||||
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
|
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
|
||||||
var sig = Convert.FromBase64String(base64Signature);
|
var sig = Convert.FromBase64String(base64Signature);
|
||||||
|
|
||||||
// Tente d'abord le canonical actuel (avec channel + canSeeBetas).
|
// Chaîne de fallback canonical pour compat ascendante :
|
||||||
// Si ça échoue, tente le canonical legacy (sans ces champs) — c'est
|
// v3 (current) : avec settingsLockPasswordHash (depuis v0.28)
|
||||||
// le format des réponses signées par les serveurs OU lancées par
|
// v2 : avec channel + canSeeBetas (v0.26 → v0.27)
|
||||||
// les clients d'avant v0.26. Sans ce fallback, toutes les licenses
|
// legacy : sans aucun de ces champs (avant v0.26)
|
||||||
// cachées localement avant l'update à v0.26 deviendraient invalides
|
// Sans ces fallbacks, les licenses cachées par d'anciennes versions
|
||||||
// et l'utilisateur serait forcé de revalider online — bloqué hors-ligne.
|
// deviendraient invalides et l'utilisateur serait forcé de revalider
|
||||||
var payloadCurrent = CanonicalBytesFor(response);
|
// online — bloqué hors-ligne.
|
||||||
if (alg.Verify(pk, payloadCurrent, sig)) return true;
|
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);
|
var payloadLegacy = CanonicalBytesForLegacy(response);
|
||||||
return alg.Verify(pk, payloadLegacy, sig);
|
return alg.Verify(pk, payloadLegacy, sig);
|
||||||
|
|||||||
@@ -692,6 +692,140 @@ public static class Strings
|
|||||||
$"⚙️ Redist {done}/{total}: {filename}"
|
$"⚙️ 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) ----
|
// ---- 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 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 (غير متصل)");
|
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 System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PSLauncher.Core.Lan;
|
using PSLauncher.Core.Lan;
|
||||||
|
using PSLauncher.Core.Security;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.Core.Manifests;
|
namespace PSLauncher.Core.Manifests;
|
||||||
@@ -20,6 +21,7 @@ public sealed class ManifestService : IManifestService
|
|||||||
private readonly Func<string?> _channelProvider;
|
private readonly Func<string?> _channelProvider;
|
||||||
private readonly IManifestCache _manifestCache;
|
private readonly IManifestCache _manifestCache;
|
||||||
private readonly IPeerManifestFetcher _peerManifestFetcher;
|
private readonly IPeerManifestFetcher _peerManifestFetcher;
|
||||||
|
private readonly ISettingsLockService _settingsLock;
|
||||||
private readonly ILogger<ManifestService> _logger;
|
private readonly ILogger<ManifestService> _logger;
|
||||||
|
|
||||||
public ManifestSource LastSource { get; private set; } = ManifestSource.None;
|
public ManifestSource LastSource { get; private set; } = ManifestSource.None;
|
||||||
@@ -30,6 +32,7 @@ public sealed class ManifestService : IManifestService
|
|||||||
Func<string?> channelProvider,
|
Func<string?> channelProvider,
|
||||||
IManifestCache manifestCache,
|
IManifestCache manifestCache,
|
||||||
IPeerManifestFetcher peerManifestFetcher,
|
IPeerManifestFetcher peerManifestFetcher,
|
||||||
|
ISettingsLockService settingsLock,
|
||||||
ILogger<ManifestService> logger)
|
ILogger<ManifestService> logger)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
@@ -37,9 +40,23 @@ public sealed class ManifestService : IManifestService
|
|||||||
_channelProvider = channelProvider;
|
_channelProvider = channelProvider;
|
||||||
_manifestCache = manifestCache;
|
_manifestCache = manifestCache;
|
||||||
_peerManifestFetcher = peerManifestFetcher;
|
_peerManifestFetcher = peerManifestFetcher;
|
||||||
|
_settingsLock = settingsLock;
|
||||||
_logger = logger;
|
_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>
|
/// <summary>
|
||||||
/// Timeout court sur la tentative OVH : si le serveur Internet ne répond pas
|
/// 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
|
/// 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");
|
?? throw new InvalidOperationException("Empty manifest");
|
||||||
await _manifestCache.SaveAsync(rawJson, ct).ConfigureAwait(false);
|
await _manifestCache.SaveAsync(rawJson, ct).ConfigureAwait(false);
|
||||||
LastSource = ManifestSource.Ovh;
|
LastSource = ManifestSource.Ovh;
|
||||||
|
PropagateSettingsLock(manifest);
|
||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||||
@@ -86,6 +104,7 @@ public sealed class ManifestService : IManifestService
|
|||||||
if (cached is not null)
|
if (cached is not null)
|
||||||
{
|
{
|
||||||
LastSource = ManifestSource.DiskCache;
|
LastSource = ManifestSource.DiskCache;
|
||||||
|
PropagateSettingsLock(cached);
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
throw;
|
throw;
|
||||||
@@ -102,6 +121,7 @@ public sealed class ManifestService : IManifestService
|
|||||||
// Cache aussi le peer manifest pour servir aux autres clients du LAN.
|
// Cache aussi le peer manifest pour servir aux autres clients du LAN.
|
||||||
await _manifestCache.SaveAsync(peerJson, ct).ConfigureAwait(false);
|
await _manifestCache.SaveAsync(peerJson, ct).ConfigureAwait(false);
|
||||||
LastSource = ManifestSource.Peer;
|
LastSource = ManifestSource.Peer;
|
||||||
|
PropagateSettingsLock(manifest);
|
||||||
return 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)");
|
_logger.LogInformation("Manifest récupéré depuis le cache disque (offline + pas de peer)");
|
||||||
LastSource = ManifestSource.DiskCache;
|
LastSource = ManifestSource.DiskCache;
|
||||||
|
PropagateSettingsLock(localCache);
|
||||||
return localCache;
|
return localCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,14 +20,32 @@ public sealed class ProcessLauncher : IProcessLauncher
|
|||||||
{
|
{
|
||||||
FileName = version.ExecutablePath,
|
FileName = version.ExecutablePath,
|
||||||
WorkingDirectory = version.FolderPath,
|
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)
|
return SysProcess.Start(psi)
|
||||||
?? throw new InvalidOperationException("Process.Start returned null");
|
?? 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")]
|
[JsonPropertyName("canSeeBetas")]
|
||||||
public bool CanSeeBetas { get; set; }
|
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")]
|
[JsonPropertyName("serverTime")]
|
||||||
public DateTime? ServerTime { get; set; }
|
public DateTime? ServerTime { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ public sealed class LocalConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public LanCacheConfig LanCache { get; set; } = new();
|
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>
|
/// <summary>
|
||||||
/// Surveillance d'état système affichée en bandeau sous la top bar
|
/// Surveillance d'état système affichée en bandeau sous la top bar
|
||||||
/// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…).
|
/// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…).
|
||||||
@@ -308,6 +315,63 @@ public sealed class HealthCheckEntry
|
|||||||
public int? PingTimeoutMs { get; set; } = 1000;
|
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 sealed class LicenseConfig
|
||||||
{
|
{
|
||||||
public string? EncryptedKey { get; set; }
|
public string? EncryptedKey { get; set; }
|
||||||
@@ -338,6 +402,14 @@ public sealed class LicenseConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool CachedCanSeeBetas { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Statut tel que renvoyé par le serveur lors de la dernière validation : "valid",
|
/// Statut tel que renvoyé par le serveur lors de la dernière validation : "valid",
|
||||||
/// "expired", "revoked", "invalid", "machine_limit_exceeded". Persisté pour qu'une
|
/// "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