Compare commits
27 Commits
aab2e41152
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d21c1f24b7 | |||
| e673b1954c | |||
| f8af338faf | |||
| d845edb90f | |||
| dc72e7eee6 | |||
| 53c9e0519c | |||
| 28c5ca5877 | |||
| f9820620dd | |||
| c20d4603a2 | |||
| d52e29151e | |||
| b0ca082b52 | |||
| aae339287b | |||
| 46d7c461c5 | |||
| ff05edbe7e | |||
| 8d8cf32e12 | |||
| e2d9171df1 | |||
| 5b489dcfc5 | |||
| a6ea9b11af | |||
| b3844bfe16 | |||
| 43a6070a65 | |||
| 73d084a703 | |||
| 500f7d12e6 | |||
| 50da755a92 | |||
| 01793eb32d | |||
| eae0514058 | |||
| efb53f079e | |||
| 9b17dfa84c |
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
#define MyAppName "PROSERVE Launcher"
|
#define MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PS_Launcher"
|
#define MyAppShortName "PS_Launcher"
|
||||||
#define MyAppVersion "0.27.2"
|
#define MyAppVersion "1.0.13"
|
||||||
#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"
|
||||||
@@ -65,6 +65,14 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription
|
|||||||
Source: "..\src\PSLauncher.App\bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\src\PSLauncher.App\bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
Source: "..\src\PSLauncher.Updater\bin\Release\net8.0-windows\win-x64\publish\{#MyUpdaterExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\src\PSLauncher.Updater\bin\Release\net8.0-windows\win-x64\publish\{#MyUpdaterExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
Source: "..\docs\PS_Launcher-Guide-Utilisateur.pptx"; DestDir: "{app}\docs"; Flags: ignoreversion
|
Source: "..\docs\PS_Launcher-Guide-Utilisateur.pptx"; DestDir: "{app}\docs"; Flags: ignoreversion
|
||||||
|
; WebView2 Evergreen Bootstrapper (Microsoft, redistribuable). 1.6 MB.
|
||||||
|
; Téléchargé depuis https://go.microsoft.com/fwlink/p/?LinkId=2124703
|
||||||
|
; Exécuté en silencieux à l'install SI WebView2 Runtime n'est pas déjà présent
|
||||||
|
; (vérification registre via la fonction Pascal IsWebView2Installed ci-dessous).
|
||||||
|
; Sans ça, sur un Win10/Server sans WebView2 préinstallé, la fenêtre du launcher
|
||||||
|
; reste toute blanche au démarrage parce que les contrôles WebView2 (Reports +
|
||||||
|
; Documentation) ne peuvent pas s'initialiser et plantent en silence le rendu UI.
|
||||||
|
Source: "redists\MicrosoftEdgeWebview2Setup.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall; Check: not IsWebView2Installed
|
||||||
|
|
||||||
[Icons]
|
[Icons]
|
||||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||||
@@ -73,6 +81,14 @@ Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
|
|||||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
|
; Installe WebView2 Runtime si pas déjà présent. /silent /install = install
|
||||||
|
; non-interactif. Le bootstrapper télécharge ~150 MB depuis Microsoft (1-2 min
|
||||||
|
; selon la connexion). StatusMsg pour que l'utilisateur voie ce qu'il se passe.
|
||||||
|
; Code exit attendu : 0 (déjà installé OU install réussie). On ne fail pas le
|
||||||
|
; setup global même si ça échoue — le launcher logue ensuite l'absence si ça
|
||||||
|
; pose problème, plutôt que de bloquer toute l'installation pour ça.
|
||||||
|
Filename: "{tmp}\MicrosoftEdgeWebview2Setup.exe"; Parameters: "/silent /install"; StatusMsg: "Installation du composant Microsoft Edge WebView2 Runtime (~150 MB, peut prendre 1-2 min)..."; Check: not IsWebView2Installed; Flags: waituntilterminated
|
||||||
|
|
||||||
; Règles firewall pour le cache LAN P2P. Profile=private,domain (PAS public) =
|
; Règles firewall pour le cache LAN P2P. Profile=private,domain (PAS public) =
|
||||||
; safety net : même si l'utilisateur connecte le PC à un Wi-Fi public, le port
|
; safety net : même si l'utilisateur connecte le PC à un Wi-Fi public, le port
|
||||||
; reste fermé. Le filtre RFC1918 dans LanCacheServer.cs est la première barrière,
|
; reste fermé. Le filtre RFC1918 dans LanCacheServer.cs est la première barrière,
|
||||||
@@ -92,3 +108,28 @@ Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=
|
|||||||
; binaires installés et le dossier d'install s'il est vide après.
|
; binaires installés et le dossier d'install s'il est vide après.
|
||||||
Type: filesandordirs; Name: "{app}\*.bak"
|
Type: filesandordirs; Name: "{app}\*.bak"
|
||||||
Type: dirifempty; Name: "{app}"
|
Type: dirifempty; Name: "{app}"
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
// Détecte si Microsoft Edge WebView2 Runtime est déjà installé sur le système.
|
||||||
|
// Référence : https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution
|
||||||
|
//
|
||||||
|
// 3 emplacements registre possibles selon le contexte d'install du runtime :
|
||||||
|
// - HKLM 64-bit (machine-wide, install par admin)
|
||||||
|
// - HKLM 32-bit (machine-wide, parfois utilisé par les versions plus anciennes)
|
||||||
|
// - HKCU (user-only, install par utilisateur sans admin)
|
||||||
|
//
|
||||||
|
// La clé contient une valeur 'pv' avec la version installée. Présence non vide
|
||||||
|
// = runtime présent. Le check Edge classique (HKLM\...\Edge\BLBeacon) ne suffit
|
||||||
|
// pas parce qu'Edge stable ≠ WebView2 Runtime (deux composants distincts).
|
||||||
|
function IsWebView2Installed(): Boolean;
|
||||||
|
var
|
||||||
|
PV: String;
|
||||||
|
begin
|
||||||
|
Result := False;
|
||||||
|
if RegQueryStringValue(HKLM, 'SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
|
||||||
|
Result := True
|
||||||
|
else if RegQueryStringValue(HKLM, 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
|
||||||
|
Result := True
|
||||||
|
else if RegQueryStringValue(HKCU, 'Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
|
||||||
|
Result := True;
|
||||||
|
end;
|
||||||
|
|||||||
BIN
installer/redists/MicrosoftEdgeWebview2Setup.exe
Normal file
BIN
installer/redists/MicrosoftEdgeWebview2Setup.exe
Normal file
Binary file not shown.
1
server/admin/.gitkeep
Normal file
1
server/admin/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Placeholder — les vrais ZIPs (proserve-X.Y.Z.zip) sont uploadés en SFTP, pas en git.
|
||||||
BIN
server/admin/assets/asterion-logo.png
Normal file
BIN
server/admin/assets/asterion-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
@@ -130,3 +130,163 @@ details[open] { background: var(--bg-elevated); padding: 8px; border-radius: 4px
|
|||||||
|
|
||||||
a { color: var(--accent); }
|
a { color: var(--accent); }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Modal (<dialog>) — éditeur d'une entrée du manifest
|
||||||
|
Remplace les <details> inline qui débordaient horizontalement
|
||||||
|
============================================================ */
|
||||||
|
/* Styles visuels appliqués au dialog OUVERT ([open] est posé par
|
||||||
|
showModal() automatiquement). Sans le sélecteur [open], les règles
|
||||||
|
`display: flex` etc. écrasaient le `display: none` du user-agent
|
||||||
|
stylesheet → tous les dialogs apparaissaient inline au lieu d'être
|
||||||
|
cachés tant que pas explicitement ouverts. */
|
||||||
|
dialog.edit-modal[open] {
|
||||||
|
background: var(--bg-card); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 0; margin: auto;
|
||||||
|
width: min(640px, 92vw); max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
dialog.edit-modal::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header {
|
||||||
|
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header h3 {
|
||||||
|
margin: 0; font-size: 15px; font-weight: 600;
|
||||||
|
flex: 1; color: var(--text);
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header .close-x {
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--text-secondary); cursor: pointer;
|
||||||
|
font-size: 20px; line-height: 1; padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header .close-x:hover {
|
||||||
|
background: var(--bg-card); color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Onglets verticaux compacts en tête */
|
||||||
|
.modal-tabs {
|
||||||
|
display: flex; gap: 4px; padding: 8px 12px 0 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.modal-tabs button {
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--text-secondary); cursor: pointer;
|
||||||
|
font-size: 13px; font-weight: 500;
|
||||||
|
padding: 8px 14px; border-radius: 4px 4px 0 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.modal-tabs button:hover {
|
||||||
|
color: var(--text); background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
.modal-tabs button.active {
|
||||||
|
color: var(--text); border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.modal-body > section[hidden] { display: none; }
|
||||||
|
.modal-body > section { display: block; }
|
||||||
|
.modal-body .field:last-child { margin-bottom: 0; }
|
||||||
|
.modal-body .hint {
|
||||||
|
color: var(--text-secondary); font-size: 11px;
|
||||||
|
margin: 4px 0 0 0; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.modal-body .warn {
|
||||||
|
color: #FBBF24; font-size: 11px;
|
||||||
|
margin: 4px 0 0 0; line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip-style note in the modal sections explaining a section purpose */
|
||||||
|
.section-intro {
|
||||||
|
background: var(--bg-input); border-left: 3px solid var(--accent);
|
||||||
|
padding: 10px 14px; border-radius: 4px;
|
||||||
|
font-size: 12px; color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Liste de checkboxes verticale propre (pour Channels) */
|
||||||
|
.checkbox-list {
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.checkbox-list label {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px; border-radius: 4px;
|
||||||
|
font-size: 13px; color: var(--text); font-weight: normal;
|
||||||
|
cursor: pointer; margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.checkbox-list label:hover { background: var(--bg-elevated); }
|
||||||
|
.checkbox-list label code { flex: 0 0 auto; }
|
||||||
|
.checkbox-list label .muted { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Actions inline d'une row de version : harmonise les hauteurs et l'espacement */
|
||||||
|
.row-actions {
|
||||||
|
display: inline-flex; gap: 6px; align-items: center;
|
||||||
|
flex-wrap: nowrap; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.row-actions .btn { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Overlay blocant affiché pendant les opérations longues
|
||||||
|
(re-hash d'un ZIP de 13 Go = 3-5 min côté serveur OVH).
|
||||||
|
Visible tant que le browser n'a pas reçu la réponse POST,
|
||||||
|
disparaît naturellement au reload de page suivant.
|
||||||
|
============================================================ */
|
||||||
|
.busy-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.busy-overlay[hidden] { display: none; }
|
||||||
|
.busy-content {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 40px 56px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.busy-content h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: none; letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.busy-content p {
|
||||||
|
margin: 6px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.busy-spinner {
|
||||||
|
width: 56px; height: 56px;
|
||||||
|
border: 4px solid var(--bg-elevated);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: busy-spin 1s linear infinite;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
@keyframes busy-spin { to { transform: rotate(360deg); } }
|
||||||
|
|||||||
@@ -42,10 +42,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if ($action === 'set_launcher') {
|
if ($action === 'set_launcher') {
|
||||||
$lver = trim($_POST['launcher_version'] ?? '');
|
$lver = trim($_POST['launcher_version'] ?? '');
|
||||||
$lmin = trim($_POST['launcher_min_required'] ?? '');
|
$lmin = trim($_POST['launcher_min_required'] ?? '');
|
||||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) {
|
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||||
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).');
|
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lver)) {
|
||||||
|
throw new Exception('Numéro de version launcher invalide (X.Y.Z ou X.Y.Z.B attendu).');
|
||||||
}
|
}
|
||||||
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+$/', $lmin)) {
|
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lmin)) {
|
||||||
throw new Exception('Numéro de version "minRequired" invalide.');
|
throw new Exception('Numéro de version "minRequired" invalide.');
|
||||||
}
|
}
|
||||||
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||||||
@@ -76,6 +77,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';
|
||||||
@@ -144,14 +146,14 @@ Layout::header('Launcher', 'launcher');
|
|||||||
<input type="hidden" name="action" value="set_launcher">
|
<input type="hidden" name="action" value="set_launcher">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col field">
|
<div class="col field">
|
||||||
<label>Version launcher (X.Y.Z)</label>
|
<label>Version launcher (X.Y.Z ou X.Y.Z.B)</label>
|
||||||
<input type="text" name="launcher_version" placeholder="0.6.0"
|
<input type="text" name="launcher_version" placeholder="0.6.0"
|
||||||
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+">
|
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+(\.\d+)?">
|
||||||
</div>
|
</div>
|
||||||
<div class="col field">
|
<div class="col field">
|
||||||
<label>Version minimale requise (X.Y.Z)</label>
|
<label>Version minimale requise (X.Y.Z ou X.Y.Z.B)</label>
|
||||||
<input type="text" name="launcher_min_required" placeholder="0.5.0"
|
<input type="text" name="launcher_min_required" placeholder="0.5.0"
|
||||||
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+">
|
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+(\.\d+)?">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-success" type="submit">Définir</button>
|
<button class="btn btn-success" type="submit">Définir</button>
|
||||||
|
|||||||
201
server/admin/lib/Mailer.php
Normal file
201
server/admin/lib/Mailer.php
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PSLauncher\Admin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Petit helper d'envoi d'emails de notification (release announce).
|
||||||
|
*
|
||||||
|
* Stratégie : on utilise <code>mail()</code> natif PHP, qui sur OVH mutualisé
|
||||||
|
* passe par le relais SMTP local et marche out-of-the-box sans config
|
||||||
|
* additionnelle. Pas de dépendance composer (PHPMailer, etc.) volontairement
|
||||||
|
* — on a besoin de notifications opportunistes, pas de delivery garantie.
|
||||||
|
*
|
||||||
|
* Pour passer à PHPMailer / SMTP externe plus tard, la surface API reste la
|
||||||
|
* même : on remplace juste <see cref="sendInternal"/> par un appel
|
||||||
|
* <code>$mailer->send()</code>.
|
||||||
|
*/
|
||||||
|
final class Mailer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Parse un blob texte saisi par l'admin (CSV, ligne par ligne, mélange…)
|
||||||
|
* en liste d'emails normalisés et validés. Tout token qui ne matche pas
|
||||||
|
* la regex email est rejeté SILENCIEUSEMENT (l'admin n'a pas besoin d'un
|
||||||
|
* popup pour un copy/paste avec une virgule en trop).
|
||||||
|
*
|
||||||
|
* @return list<string> emails uniques lowercase
|
||||||
|
*/
|
||||||
|
public static function parseEmails(?string $raw): array
|
||||||
|
{
|
||||||
|
if ($raw === null || trim($raw) === '') return [];
|
||||||
|
// Split sur tout séparateur courant : virgule, point-virgule, espace,
|
||||||
|
// newline. Permet à l'admin de coller un copy/paste depuis Outlook,
|
||||||
|
// Excel, ou ligne par ligne — peu importe.
|
||||||
|
$tokens = preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($tokens as $t) {
|
||||||
|
$t = strtolower(trim($t));
|
||||||
|
if ($t === '') continue;
|
||||||
|
if (!filter_var($t, FILTER_VALIDATE_EMAIL)) continue;
|
||||||
|
if (!in_array($t, $out, true)) $out[] = $t;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse : prend un tableau d'emails et le formate pour stockage DB
|
||||||
|
* (séparé par virgules). NULL si la liste est vide pour économiser
|
||||||
|
* une ligne en DB sur les licenses sans contacts.
|
||||||
|
*/
|
||||||
|
public static function joinForStorage(array $emails): ?string
|
||||||
|
{
|
||||||
|
$emails = array_values(array_filter(
|
||||||
|
array_map('trim', $emails),
|
||||||
|
fn($e) => $e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)
|
||||||
|
));
|
||||||
|
return empty($emails) ? null : implode(', ', $emails);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un email HTML (avec partie texte alternative pour les MUA legacy)
|
||||||
|
* à un destinataire. Best-effort : retourne true en cas de succès apparent
|
||||||
|
* du sink mail(), false si rejet immédiat. Erreurs de delivery côté SMTP
|
||||||
|
* relais (bounce, deferred) ne sont PAS détectées ici — on log juste le
|
||||||
|
* résultat brut.
|
||||||
|
*
|
||||||
|
* Si <paramref name="$inlineImages"/> est non vide, on construit une structure
|
||||||
|
* MIME <c>multipart/related</c> (CID embed) au lieu de <c>multipart/alternative</c>.
|
||||||
|
* Les images sont attachées comme pièces jointes inline, référencées dans
|
||||||
|
* le HTML via <c><img src="cid:..."></c>. Avantage vs hotlink HTTP :
|
||||||
|
* Outlook (et MUA généralement) affiche l'image SANS demander de permission
|
||||||
|
* (vs hotlink bloqué par défaut pour anti-tracking pixel).
|
||||||
|
*
|
||||||
|
* @param array $notifConfig bloc $config['notifications'] (from_address, from_name, reply_to)
|
||||||
|
* @param string $to destinataire UNIQUE (pour multi-destinataires, appeler en boucle)
|
||||||
|
* @param string $subject sujet en clair (sera Q-encoded UTF-8 automatiquement)
|
||||||
|
* @param string $bodyHtml contenu HTML (multipart alt avec text auto-généré)
|
||||||
|
* @param array $inlineImages liste d'images à embed inline. Chaque entrée :
|
||||||
|
* ['cid' => 'logo', 'path' => '/abs/path/file.png', 'type' => 'image/png']
|
||||||
|
*/
|
||||||
|
public static function send(array $notifConfig, string $to, string $subject, string $bodyHtml, array $inlineImages = []): bool
|
||||||
|
{
|
||||||
|
$from = $notifConfig['from_address'] ?? 'no-reply@localhost';
|
||||||
|
$fromName = $notifConfig['from_name'] ?? 'PROSERVE Launcher';
|
||||||
|
$replyTo = $notifConfig['reply_to'] ?? '';
|
||||||
|
|
||||||
|
// Filtre les inline images : ne garde que celles dont le fichier existe
|
||||||
|
// ET est lisible. Si une image manque, on log silencieusement et on
|
||||||
|
// continue avec les autres — l'email part de toute façon.
|
||||||
|
$validImages = [];
|
||||||
|
foreach ($inlineImages as $img) {
|
||||||
|
$p = $img['path'] ?? '';
|
||||||
|
if ($p === '' || !is_file($p) || !is_readable($p)) continue;
|
||||||
|
$validImages[] = $img;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boundaries uniques. Si on a des images, on a 2 niveaux d'imbrication :
|
||||||
|
// multipart/related contenant multipart/alternative + image(s).
|
||||||
|
$boundaryAlt = '=_PSLauncherAlt_' . bin2hex(random_bytes(8));
|
||||||
|
$boundaryRel = '=_PSLauncherRel_' . bin2hex(random_bytes(8));
|
||||||
|
|
||||||
|
// Headers RFC 5322 + MIME. Pas de "Bcc:" — on appelle send() par destinataire
|
||||||
|
// pour avoir des "Per-user emails" et faciliter le debugging.
|
||||||
|
$contentType = empty($validImages)
|
||||||
|
? "multipart/alternative; boundary=\"{$boundaryAlt}\""
|
||||||
|
: "multipart/related; type=\"multipart/alternative\"; boundary=\"{$boundaryRel}\"";
|
||||||
|
$headers = [
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
"Content-Type: {$contentType}",
|
||||||
|
'From: ' . self::formatAddress($fromName, $from),
|
||||||
|
'X-Mailer: PSLauncher-Admin',
|
||||||
|
'X-Auto-Response-Suppress: All', // mute autoresponders OOO
|
||||||
|
];
|
||||||
|
if ($replyTo !== '' && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$headers[] = 'Reply-To: ' . $replyTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Construction du body ===
|
||||||
|
$bodyText = self::htmlToPlainText($bodyHtml);
|
||||||
|
|
||||||
|
// Bloc multipart/alternative (text + html)
|
||||||
|
$altPart = "--{$boundaryAlt}\r\n";
|
||||||
|
$altPart .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
|
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||||
|
$altPart .= $bodyText . "\r\n\r\n";
|
||||||
|
$altPart .= "--{$boundaryAlt}\r\n";
|
||||||
|
$altPart .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||||
|
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||||
|
$altPart .= $bodyHtml . "\r\n\r\n";
|
||||||
|
$altPart .= "--{$boundaryAlt}--\r\n";
|
||||||
|
|
||||||
|
if (empty($validImages)) {
|
||||||
|
// Pas d'images → email simple, juste l'alternative
|
||||||
|
$body = $altPart;
|
||||||
|
} else {
|
||||||
|
// Avec images → multipart/related wrappant l'alt + chaque image
|
||||||
|
$body = "--{$boundaryRel}\r\n";
|
||||||
|
$body .= "Content-Type: multipart/alternative; boundary=\"{$boundaryAlt}\"\r\n\r\n";
|
||||||
|
$body .= $altPart;
|
||||||
|
foreach ($validImages as $img) {
|
||||||
|
$cid = $img['cid'] ?? bin2hex(random_bytes(6));
|
||||||
|
$type = $img['type'] ?? 'application/octet-stream';
|
||||||
|
$path = $img['path'];
|
||||||
|
$name = basename($path);
|
||||||
|
$data = @file_get_contents($path);
|
||||||
|
if ($data === false) continue;
|
||||||
|
$b64 = chunk_split(base64_encode($data), 76, "\r\n");
|
||||||
|
$body .= "\r\n--{$boundaryRel}\r\n";
|
||||||
|
$body .= "Content-Type: {$type}; name=\"{$name}\"\r\n";
|
||||||
|
$body .= "Content-Transfer-Encoding: base64\r\n";
|
||||||
|
$body .= "Content-ID: <{$cid}>\r\n";
|
||||||
|
$body .= "Content-Disposition: inline; filename=\"{$name}\"\r\n\r\n";
|
||||||
|
$body .= $b64;
|
||||||
|
}
|
||||||
|
$body .= "\r\n--{$boundaryRel}--\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectEncoded = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return @mail($to, $subjectEncoded, $body, implode("\r\n", $headers));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit un HTML simple en plain text pour la partie text/plain du
|
||||||
|
* multipart. Pas une vraie conversion (ce n'est pas html2text), mais
|
||||||
|
* suffisant pour nos templates qui sont quasi-text avec quelques tags.
|
||||||
|
*/
|
||||||
|
private static function htmlToPlainText(string $html): string
|
||||||
|
{
|
||||||
|
// Convert <br> et <p> en sauts de ligne avant strip_tags
|
||||||
|
$s = preg_replace('#<br\s*/?>#i', "\n", $html) ?? $html;
|
||||||
|
$s = preg_replace('#</p>\s*<p[^>]*>#i', "\n\n", $s) ?? $s;
|
||||||
|
$s = preg_replace('#<a\s[^>]*href="([^"]+)"[^>]*>([^<]+)</a>#i', '$2 ($1)', $s) ?? $s;
|
||||||
|
$s = strip_tags($s);
|
||||||
|
$s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
return trim($s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Pretty Name <email@x>" si name non vide, sinon "email@x" brut.
|
||||||
|
* Échappe les chars spéciaux conformément à RFC 5322.
|
||||||
|
*/
|
||||||
|
private static function formatAddress(string $name, string $email): string
|
||||||
|
{
|
||||||
|
$name = trim($name);
|
||||||
|
if ($name === '') return $email;
|
||||||
|
// Encode le name en MIME si pas pur ASCII
|
||||||
|
if (preg_match('/[\x80-\xFF]/', $name)) {
|
||||||
|
$name = '=?UTF-8?B?' . base64_encode($name) . '?=';
|
||||||
|
} else {
|
||||||
|
// Quote si chars spéciaux RFC : "()<>@,;:\\\"/.\\[\\]"
|
||||||
|
if (preg_match('/[()<>@,;:\\\"\/\[\]]/', $name)) {
|
||||||
|
$name = '"' . str_replace('"', '\\"', $name) . '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "{$name} <{$email}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ declare(strict_types=1);
|
|||||||
require __DIR__ . '/lib/Auth.php';
|
require __DIR__ . '/lib/Auth.php';
|
||||||
require __DIR__ . '/lib/Layout.php';
|
require __DIR__ . '/lib/Layout.php';
|
||||||
require __DIR__ . '/lib/Channels.php';
|
require __DIR__ . '/lib/Channels.php';
|
||||||
|
require __DIR__ . '/lib/Mailer.php';
|
||||||
require __DIR__ . '/../api/lib/Db.php';
|
require __DIR__ . '/../api/lib/Db.php';
|
||||||
|
|
||||||
use PSLauncher\Admin\Auth;
|
use PSLauncher\Admin\Auth;
|
||||||
use PSLauncher\Admin\Layout;
|
use PSLauncher\Admin\Layout;
|
||||||
use PSLauncher\Admin\Channels;
|
use PSLauncher\Admin\Channels;
|
||||||
|
use PSLauncher\Admin\Mailer;
|
||||||
use PSLauncher\Db;
|
use PSLauncher\Db;
|
||||||
|
|
||||||
Auth::requireLogin();
|
Auth::requireLogin();
|
||||||
@@ -117,13 +119,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
|
$message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
|
||||||
}
|
}
|
||||||
elseif ($action === 'toggle_betas') {
|
elseif ($action === 'toggle_betas') {
|
||||||
|
// Renommé semantically (toggle → set explicit) sans casser le nom
|
||||||
|
// de l'action POST pour préserver les éventuels liens externes
|
||||||
|
// existants. La modal envoie maintenant `enabled=1` si checkbox
|
||||||
|
// cochée, sinon le param est absent (= 0). Plus de flip aveugle.
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
$stmt = $db->prepare('UPDATE licenses SET can_see_betas = 1 - can_see_betas WHERE id = ?');
|
$enabled = isset($_POST['enabled']) ? 1 : 0;
|
||||||
$stmt->execute([$id]);
|
$db->prepare('UPDATE licenses SET can_see_betas = ? WHERE id = ?')->execute([$enabled, $id]);
|
||||||
$row = $db->prepare('SELECT can_see_betas FROM licenses WHERE id = ?');
|
$message = $enabled
|
||||||
$row->execute([$id]);
|
|
||||||
$now = (int)$row->fetchColumn();
|
|
||||||
$message = $now
|
|
||||||
? "License #{$id} : accès aux versions BÊTA activé."
|
? "License #{$id} : accès aux versions BÊTA activé."
|
||||||
: "License #{$id} : accès aux versions BÊTA désactivé.";
|
: "License #{$id} : accès aux versions BÊTA désactivé.";
|
||||||
}
|
}
|
||||||
@@ -145,6 +148,56 @@ 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 === 'set_contact_emails') {
|
||||||
|
// Met à jour la liste des emails de contact + la langue préférée
|
||||||
|
// pour les notifications de nouvelles versions. L'admin saisit en
|
||||||
|
// texte libre (1 par ligne, virgules, point-virgules — peu importe),
|
||||||
|
// on parse + valide via FILTER_VALIDATE_EMAIL et on stocke en CSV.
|
||||||
|
// Vide / aucun email valide → on stocke NULL (= pas de notif possible
|
||||||
|
// pour cette license, juste skip silencieux à l'envoi).
|
||||||
|
//
|
||||||
|
// Language : whitelist stricte fr/en/zh/th/ar (= ce que le launcher
|
||||||
|
// supporte côté Strings.cs). NULL ou inconnu → fallback English.
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$raw = (string)($_POST['contact_emails'] ?? '');
|
||||||
|
$lang = trim((string)($_POST['language'] ?? ''));
|
||||||
|
if ($id <= 0) {
|
||||||
|
throw new Exception('License id invalide.');
|
||||||
|
}
|
||||||
|
$allowedLangs = ['fr', 'en', 'es', 'de', 'zh', 'th', 'ar'];
|
||||||
|
if ($lang !== '' && !in_array($lang, $allowedLangs, true)) {
|
||||||
|
throw new Exception("Code langue invalide : '{$lang}'. Attendu : " . implode(', ', $allowedLangs) . ' ou vide.');
|
||||||
|
}
|
||||||
|
$parsed = Mailer::parseEmails($raw);
|
||||||
|
$stored = Mailer::joinForStorage($parsed);
|
||||||
|
$db->prepare('UPDATE licenses SET contact_emails = ?, language = ? WHERE id = ?')
|
||||||
|
->execute([$stored, $lang !== '' ? $lang : null, $id]);
|
||||||
|
$count = count($parsed);
|
||||||
|
$langDisplay = $lang !== '' ? $lang : 'en (défaut)';
|
||||||
|
$message = $count === 0
|
||||||
|
? "License #{$id} : contacts de notification supprimés. Langue email : {$langDisplay}."
|
||||||
|
: "License #{$id} : {$count} contact(s) email enregistré(s), langue email : {$langDisplay}.";
|
||||||
|
}
|
||||||
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]);
|
||||||
@@ -275,8 +328,21 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<td>#<?= $l['id'] ?></td>
|
<td>#<?= $l['id'] ?></td>
|
||||||
<td>
|
<td>
|
||||||
<strong><?= htmlspecialchars($l['owner_name']) ?></strong>
|
<strong><?= htmlspecialchars($l['owner_name']) ?></strong>
|
||||||
|
<?php // Clé license affichée en monospace + bouton 1-clic
|
||||||
|
// pour copier dans le presse-papier. Permet à l'admin
|
||||||
|
// de la transmettre rapidement à un client sans passer
|
||||||
|
// par phpMyAdmin. Sensibilité OK : la page admin est
|
||||||
|
// déjà derrière auth + la clé seule ne suffit pas
|
||||||
|
// (validation serveur HMAC + machine_id requis). ?>
|
||||||
|
<div style="font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px;">
|
||||||
|
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 11px; color: #C5E1A5; word-break: break-all;"><?= htmlspecialchars($l['license_key']) ?></code>
|
||||||
|
<button type="button" class="copy-btn"
|
||||||
|
data-copy="<?= htmlspecialchars($l['license_key'], ENT_QUOTES) ?>"
|
||||||
|
title="Copier la clé license"
|
||||||
|
style="background: transparent; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; padding: 2px 8px; border-radius: 3px; font-size: 11px; line-height: 1;">📋</button>
|
||||||
|
</div>
|
||||||
<?php if (!empty($l['notes'])): ?>
|
<?php if (!empty($l['notes'])): ?>
|
||||||
<div class="muted" style="font-size: 11px;"><?= htmlspecialchars($l['notes']) ?></div>
|
<div class="muted" style="font-size: 11px; margin-top: 4px;"><?= htmlspecialchars($l['notes']) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
|
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
|
||||||
@@ -293,101 +359,287 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<!-- Affichage de l'état actuel : channel + badge BÊTA si actif -->
|
<!-- READ-ONLY : channel actuel + badge BÊTA si actif. Tous
|
||||||
<div style="margin-bottom: 6px;">
|
les éditeurs sont passés dans le modal pour éviter
|
||||||
|
l'encombrement de la cellule. -->
|
||||||
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
|
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
|
||||||
title="Channel actuellement attribué à cette license">
|
title="Channel actuellement attribué">
|
||||||
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
|
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
|
||||||
</code>
|
</code>
|
||||||
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
|
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
|
||||||
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
|
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
|
||||||
|
<?php if ($hasLock): ?>
|
||||||
<!-- Toggle BÊTA en bouton DIRECTEMENT visible (pas dans un details).
|
<span class="badge badge-secondary" style="margin-left: 4px;" title="Section Settings → Avancés verrouillée par mot de passe">🔒</span>
|
||||||
L'admin n'a plus à deviner qu'il faut cliquer sur le code du channel
|
<?php endif; ?>
|
||||||
pour trouver l'option. -->
|
|
||||||
<form method="post" style="display: inline-block; margin-right: 4px;">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="toggle_betas">
|
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
|
||||||
<button class="btn <?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'btn-warning' : 'btn-secondary' ?>"
|
|
||||||
type="submit" style="font-size: 11px; padding: 4px 10px;"
|
|
||||||
title="Bascule l'accès aux versions taggées BÊTA pour cette license">
|
|
||||||
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? '✓ BÊTA actif' : 'Activer BÊTA' ?>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Channel : modifier via un menu compact -->
|
|
||||||
<details style="display: inline-block;">
|
|
||||||
<summary class="btn btn-secondary" style="font-size: 11px; padding: 4px 10px; list-style: none;">
|
|
||||||
✎ Channel
|
|
||||||
</summary>
|
|
||||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="set_channel">
|
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
|
||||||
<select name="channel" style="min-width: 160px;">
|
|
||||||
<?php foreach ($availableChannels as $value => $label):
|
|
||||||
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
|
|
||||||
?>
|
|
||||||
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-primary" type="submit">OK</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
|
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
|
||||||
<td style="text-align:right; white-space: nowrap;">
|
<td style="text-align:right; white-space: nowrap;">
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
<?php $modalId = 'edit-license-' . (int)$l['id']; ?>
|
||||||
<summary class="btn btn-secondary">Prolonger</summary>
|
<div class="row-actions">
|
||||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
<button type="button" class="btn btn-primary"
|
||||||
<?= Layout::csrfField() ?>
|
onclick="document.getElementById('<?= $modalId ?>').showModal()">
|
||||||
<input type="hidden" name="action" value="extend">
|
✎ Modifier
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
</button>
|
||||||
<input type="date" name="new_until" required style="width: 150px;">
|
|
||||||
<button class="btn btn-primary" type="submit">OK</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
<details style="display: inline-block; margin: 0 4px;">
|
|
||||||
<summary class="btn btn-secondary">Slots</summary>
|
|
||||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="set_max_machines">
|
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
|
||||||
<input type="number" name="max_machines" min="1" max="100"
|
|
||||||
value="<?= (int)$l['max_machines'] ?>" required
|
|
||||||
style="width: 70px;"
|
|
||||||
title="Nombre max de machines (≥ <?= (int)$l['machines_count'] ?> actuellement actives)">
|
|
||||||
<button class="btn btn-primary" type="submit">OK</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
<?php if ($l['machines_count'] > 0): ?>
|
|
||||||
<form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="reset_machines">
|
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
|
||||||
<button class="btn btn-secondary" type="submit" title="Libère TOUS les slots machines de cette license d'un coup">Libérer toutes</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (!$isRevoked): ?>
|
<?php if (!$isRevoked): ?>
|
||||||
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
|
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="revoke">
|
<input type="hidden" name="action" value="revoke">
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
<button class="btn btn-danger" type="submit">Révoquer</button>
|
<button class="btn btn-danger" type="submit">✕ Révoquer</button>
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<form method="post" style="display:inline">
|
<form method="post" style="display:inline">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="unrevoke">
|
<input type="hidden" name="action" value="unrevoke">
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
<button class="btn btn-secondary" type="submit">Réactiver</button>
|
<button class="btn btn-secondary" type="submit">↩ Réactiver</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<?php
|
||||||
|
// Modal d'édition (capturé en buffer, flush après </table>).
|
||||||
|
// Même pattern que versions.php : <dialog> dans <table> est
|
||||||
|
// foster-parented imprévisiblement → on émet hors de la table.
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<dialog id="<?= $modalId ?>" class="edit-modal">
|
||||||
|
<header>
|
||||||
|
<h3>License #<?= $l['id'] ?> — <?= htmlspecialchars($l['owner_name']) ?></h3>
|
||||||
|
<button type="button" class="close-x" aria-label="Fermer"
|
||||||
|
onclick="this.closest('dialog').close()">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="modal-tabs">
|
||||||
|
<button type="button" data-tab="prolong" class="active">Prolonger</button>
|
||||||
|
<button type="button" data-tab="slots">Slots</button>
|
||||||
|
<button type="button" data-tab="channel">Channel</button>
|
||||||
|
<button type="button" data-tab="beta">BÊTA</button>
|
||||||
|
<button type="button" data-tab="lock">Lock</button>
|
||||||
|
<button type="button" data-tab="contacts">Contacts</button>
|
||||||
|
<button type="button" data-tab="machines">Machines</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- TAB : Prolonger -->
|
||||||
|
<section data-tab="prolong">
|
||||||
|
<p class="section-intro">
|
||||||
|
Repousse la date d'expiration de l'entitlement.
|
||||||
|
Actuelle : <strong><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></strong>
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="extend">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Nouvelle date d'expiration</label>
|
||||||
|
<input type="date" name="new_until" required>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Prolonger</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Slots -->
|
||||||
|
<section data-tab="slots" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Nombre maximum de machines pouvant être activées simultanément.
|
||||||
|
Actuellement <strong><?= (int)$l['machines_count'] ?></strong>
|
||||||
|
active(s) sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
|
||||||
|
Pour réduire en-dessous du nombre actif, libère d'abord les slots
|
||||||
|
via l'onglet « Machines ».
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_max_machines">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Slots max</label>
|
||||||
|
<input type="number" name="max_machines" min="1" max="100"
|
||||||
|
value="<?= (int)$l['max_machines'] ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Slots</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Channel -->
|
||||||
|
<section data-tab="channel" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Quel manifest sert cette license ? « default » = manifest public.
|
||||||
|
Les channels privés (gérés sur la page <a href="channels.php">Channels</a>)
|
||||||
|
permettent de servir des versions ciblées (police, firefighter, …).
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_channel">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Channel</label>
|
||||||
|
<select name="channel">
|
||||||
|
<?php foreach ($availableChannels as $value => $label):
|
||||||
|
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
|
||||||
|
?>
|
||||||
|
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Channel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : BÊTA -->
|
||||||
|
<section data-tab="beta" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Si activé, le client voit en plus des versions stables toutes les
|
||||||
|
versions taggées <code>isBeta=true</code> dans le manifest. Sinon
|
||||||
|
elles lui sont invisibles (filtrées côté manifest signé).
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="toggle_betas">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="enabled" value="1"
|
||||||
|
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'checked' : '' ?>>
|
||||||
|
Voir les versions BÊTA
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Lock (settings password) -->
|
||||||
|
<section data-tab="lock" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Mot de passe qui verrouille la section Settings → Avancés du
|
||||||
|
launcher pour cette license. Vide = retirer le verrouillage.
|
||||||
|
Actuellement : <strong><?= $hasLock ? 'verrouillé 🔒' : 'pas verrouillé' ?></strong>
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_settings_lock_password">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Mot de passe (≥4 caractères, ou vide pour retirer)</label>
|
||||||
|
<input type="password" name="settings_lock_password"
|
||||||
|
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe' ?>"
|
||||||
|
minlength="0" maxlength="100"
|
||||||
|
autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Lock</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Contacts (emails de notification release) -->
|
||||||
|
<section data-tab="contacts" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Liste d'adresses email à notifier quand tu publies une nouvelle
|
||||||
|
version de PROSERVE accessible à cette license (matching channel +
|
||||||
|
entitlement). Format libre : une adresse par ligne, virgules, ou
|
||||||
|
point-virgules. Adresses invalides ignorées silencieusement.
|
||||||
|
La langue choisie ci-dessous est utilisée pour le contenu de l'email
|
||||||
|
(greeting + instructions) — les release notes elles-mêmes restent
|
||||||
|
toujours en anglais.
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
// Affichage en mode "1 par ligne" pour la lisibilité, peu importe
|
||||||
|
// comment c'est stocké en DB (CSV).
|
||||||
|
$currentEmails = Mailer::parseEmails($l['contact_emails'] ?? '');
|
||||||
|
$currentDisplay = implode("\n", $currentEmails);
|
||||||
|
$currentLang = trim((string)($l['language'] ?? ''));
|
||||||
|
$langOptions = [
|
||||||
|
'' => 'English (defaut)',
|
||||||
|
'fr' => 'Français',
|
||||||
|
'en' => 'English',
|
||||||
|
'es' => 'Español',
|
||||||
|
'de' => 'Deutsch',
|
||||||
|
'zh' => '中文 (Chinese)',
|
||||||
|
'th' => 'ภาษาไทย (Thai)',
|
||||||
|
'ar' => 'العربية (Arabic)',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_contact_emails">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Langue de l'email</label>
|
||||||
|
<select name="language">
|
||||||
|
<?php foreach ($langOptions as $code => $label):
|
||||||
|
$sel = ($currentLang === $code) ? 'selected' : '';
|
||||||
|
?>
|
||||||
|
<option value="<?= htmlspecialchars($code) ?>" <?= $sel ?>><?= htmlspecialchars($label) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<p class="hint" style="margin: 4px 0 0;">
|
||||||
|
Langue dans laquelle l'email de notification sera rédigé.
|
||||||
|
Le contenu des release notes reste toujours en anglais.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Adresses email <span class="muted" style="font-weight: normal;">(<?= count($currentEmails) ?> actuellement)</span></label>
|
||||||
|
<textarea name="contact_emails" rows="6"
|
||||||
|
placeholder="ops@asterionvr.com jerome@client.com technicien@partner.fr"
|
||||||
|
style="font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px;"><?= htmlspecialchars($currentDisplay) ?></textarea>
|
||||||
|
<p class="hint" style="margin: 4px 0 0;">
|
||||||
|
Une par ligne, ou séparées par <code>,</code> / <code>;</code>.
|
||||||
|
Vider le champ = retirer toutes les notifications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Contacts</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Machines -->
|
||||||
|
<section data-tab="machines" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
<strong><?= (int)$l['machines_count'] ?></strong> machine(s) active(s)
|
||||||
|
sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
|
||||||
|
La gestion fine machine par machine se fait via le tableau dépliable
|
||||||
|
ci-dessous (clic sur « X / Y » dans la colonne Machines de la row).
|
||||||
|
</p>
|
||||||
|
<?php if ($l['machines_count'] > 0): ?>
|
||||||
|
<div class="field">
|
||||||
|
<label>Action en bloc</label>
|
||||||
|
<form method="post" style="display:inline"
|
||||||
|
onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nLes slots redeviendront disponibles pour de nouvelles activations.')">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="reset_machines">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<button class="btn btn-warning" type="submit">Libérer TOUTES les machines</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="muted" style="font-style: italic;">Aucune machine active sur cette license.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<?php $GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean(); ?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
// Sous-row dépliable : liste des machines avec remove individuel.
|
// Sous-row dépliable : liste des machines avec remove individuel.
|
||||||
@@ -434,6 +686,14 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align:right;">
|
<td style="text-align:right;">
|
||||||
|
<?php // Note : pas de "Déplacer vers une autre license" ici —
|
||||||
|
// ce serait une feature DB-only fragile (le launcher
|
||||||
|
// ré-activerait automatiquement la machine sur sa
|
||||||
|
// license d'origine au prochain validate puisque sa
|
||||||
|
// clé license stockée n'a pas changé). La migration
|
||||||
|
// d'une machine vers une autre license se fait côté
|
||||||
|
// launcher : Settings → License → entrer la nouvelle
|
||||||
|
// clé. ?>
|
||||||
<form method="post" style="display:inline"
|
<form method="post" style="display:inline"
|
||||||
onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
|
onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
@@ -462,4 +722,70 @@ Layout::header('Licenses', 'licenses');
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php // Dialogs émis hors de la <table> pour que showModal() fonctionne. ?>
|
||||||
|
<?= $GLOBALS['__editDialogs'] ?? '' ?>
|
||||||
|
|
||||||
|
<!-- Handler global pour les onglets des modals (délégation sur document). -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
// 1. Tabs des modals d'édition license
|
||||||
|
var btn = e.target.closest('.modal-tabs button[data-tab]');
|
||||||
|
if (btn) {
|
||||||
|
var tabName = btn.dataset.tab;
|
||||||
|
var modal = btn.closest('dialog');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
|
||||||
|
b.classList.toggle('active', b === btn);
|
||||||
|
});
|
||||||
|
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
|
||||||
|
s.hidden = s.dataset.tab !== tabName;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Boutons "📋 Copier" (clé license) — copie data-copy via Clipboard API
|
||||||
|
// + feedback visuel temporaire (1.5 s) sur le bouton lui-même.
|
||||||
|
var copyBtn = e.target.closest('[data-copy]');
|
||||||
|
if (copyBtn) {
|
||||||
|
var text = copyBtn.dataset.copy || '';
|
||||||
|
var original = copyBtn.textContent;
|
||||||
|
// Clipboard API moderne (Chrome 66+, Firefox 63+, Edge 79+, Safari 13.1+).
|
||||||
|
// Fallback execCommand pour les browsers très anciens.
|
||||||
|
var copyOk = function () {
|
||||||
|
copyBtn.textContent = '✓';
|
||||||
|
copyBtn.style.color = '#16A34A';
|
||||||
|
setTimeout(function () {
|
||||||
|
copyBtn.textContent = original;
|
||||||
|
copyBtn.style.color = '';
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(copyOk, function () {
|
||||||
|
// Échec d'autorisation (rare en HTTPS sur même origine) — fallback
|
||||||
|
fallbackCopy(text, copyOk);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text, copyOk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback copie via textarea hidden + execCommand. Marche dans tous les
|
||||||
|
// browsers sans avoir besoin de la permission Clipboard API.
|
||||||
|
function fallbackCopy(text, onSuccess) {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try { document.execCommand('copy'); onSuccess(); }
|
||||||
|
catch (e) { /* silent */ }
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<?php Layout::footer();
|
<?php Layout::footer();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,4 +40,32 @@ return [
|
|||||||
// php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);"
|
// php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);"
|
||||||
// puis colle le résultat ci-dessous.
|
// puis colle le résultat ci-dessous.
|
||||||
'admin_password_hash' => '',
|
'admin_password_hash' => '',
|
||||||
|
|
||||||
|
// === NOTIFICATIONS EMAIL (release announce) ===
|
||||||
|
// Utilisé par versions.php → action « ✉ Notifier » pour prévenir les
|
||||||
|
// contacts d'une license de la sortie d'une nouvelle version.
|
||||||
|
// Si SMTP non configuré, on retombe sur mail() natif PHP (qui marche sur
|
||||||
|
// OVH mutualisé via le relais SMTP local sans config additionnelle).
|
||||||
|
'notifications' => [
|
||||||
|
// Adresse "From" de l'email (doit être autorisée par OVH — typiquement
|
||||||
|
// une adresse hébergée sur ton domaine principal).
|
||||||
|
'from_address' => 'no-reply@asterionvr.com',
|
||||||
|
'from_name' => 'ASTERION VR — PROSERVE Launcher',
|
||||||
|
// Optionnel : Reply-To si tu veux que les réponses arrivent ailleurs
|
||||||
|
// (ex. boîte support partagée). Vide = pas de header Reply-To.
|
||||||
|
'reply_to' => '',
|
||||||
|
// Logo affiché en haut de l'email. Par défaut, l'image est EMBED INLINE
|
||||||
|
// via CID (Content-ID, pièce jointe MIME multipart/related). Avantage
|
||||||
|
// vs hotlink HTTP : Outlook (et la plupart des MUA) affiche l'image
|
||||||
|
// SANS demander la permission « Télécharger les images » au destinataire.
|
||||||
|
// Le path par défaut pointe vers admin/assets/asterion-logo.png — copié
|
||||||
|
// depuis le repo, tu peux remplacer le fichier librement (même URL servie).
|
||||||
|
'logo_path' => null, // null = auto-détect admin/assets/asterion-logo.png
|
||||||
|
// Fallback URL hotlink si tu préfères servir le logo depuis un CDN
|
||||||
|
// public au lieu du CID embed. UTILISÉ UNIQUEMENT si logo_path est null
|
||||||
|
// ou que le fichier est introuvable. Note : les MUA bloquent souvent
|
||||||
|
// les hotlinks par défaut → le destinataire devra cliquer « afficher
|
||||||
|
// les images » pour voir le logo. Préfère le CID embed (laisse logo_path).
|
||||||
|
'logo_url' => '',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -49,8 +49,9 @@ if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release notes : accepte 'X.Y.Z' (legacy) ou id stable 'v' + 8 hex (v0.27.1+)
|
// Release notes : accepte 'X.Y.Z' ou 'X.Y.Z.B' (4ᵉ digit = itération dev/test
|
||||||
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
|
// optionnelle) OU id stable 'v' + 8 hex (v0.27.1+).
|
||||||
|
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
|
||||||
require __DIR__ . '/routes/Releasenotes.php';
|
require __DIR__ . '/routes/Releasenotes.php';
|
||||||
\PSLauncher\Routes\Releasenotes::handle($m[1]);
|
\PSLauncher\Routes\Releasenotes::handle($m[1]);
|
||||||
return;
|
return;
|
||||||
@@ -64,7 +65,8 @@ if ($method === 'POST' && $route === 'license/validate') {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
|
// Download URL : accepte X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
|
||||||
|
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
|
||||||
require __DIR__ . '/lib/Db.php';
|
require __DIR__ . '/lib/Db.php';
|
||||||
require __DIR__ . '/lib/Crypto.php';
|
require __DIR__ . '/lib/Crypto.php';
|
||||||
require __DIR__ . '/routes/DownloadUrl.php';
|
require __DIR__ . '/routes/DownloadUrl.php';
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ final class DownloadUrl
|
|||||||
{
|
{
|
||||||
public static function handle(array $config, string $version): void
|
public static function handle(array $config, string $version): void
|
||||||
{
|
{
|
||||||
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
|
// Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
|
||||||
|
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $version)) {
|
||||||
Response::error('invalid_version', 'Version invalide', 400);
|
Response::error('invalid_version', 'Version invalide', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,12 +75,36 @@ final class DownloadUrl
|
|||||||
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
|
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
|
||||||
}
|
}
|
||||||
$manifest = json_decode(file_get_contents($manifestPath), true);
|
$manifest = json_decode(file_get_contents($manifestPath), true);
|
||||||
|
|
||||||
|
// Filename attendu, envoyé par le client (extrait de son propre manifest signé).
|
||||||
|
// Sert à disambiguer les manifestes multi-channels où plusieurs entrées
|
||||||
|
// partagent le même numéro de version (ex : proserve-firefighter-1.5.4.32 et
|
||||||
|
// proserve-full-1.5.4.32 sur v1.5.4.32). Sans ce filtre, le foreach ci-dessous
|
||||||
|
// retournait la 1re entrée matchant le numéro → l'URL signée pointait vers le
|
||||||
|
// MAUVAIS ZIP, et le client détectait le mismatch filename manifest vs signé
|
||||||
|
// et abortait (garde-fou côté MainViewModel.InstallVersionAsync). Optionnel
|
||||||
|
// pour rétro-compat avec les vieux clients (v1.0.4-) qui ne l'envoient pas ;
|
||||||
|
// dans ce cas la 1re entrée gagne, comme avant.
|
||||||
|
$expectedFilename = trim((string)($_GET['filename'] ?? ''));
|
||||||
|
// Whitelist défensive — même règle que la validation du filename lu du manifest
|
||||||
|
// plus bas dans cette route. Bloque path traversal via query param.
|
||||||
|
if ($expectedFilename !== '' && !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $expectedFilename)) {
|
||||||
|
Response::error('invalid_filename', "Query filename invalide : '{$expectedFilename}'", 400);
|
||||||
|
}
|
||||||
|
|
||||||
$entry = null;
|
$entry = null;
|
||||||
foreach ($manifest['versions'] ?? [] as $v) {
|
foreach ($manifest['versions'] ?? [] as $v) {
|
||||||
if (($v['version'] ?? '') === $version) { $entry = $v; break; }
|
if (($v['version'] ?? '') !== $version) continue;
|
||||||
|
if ($expectedFilename !== '') {
|
||||||
|
$entryFilename = basename(parse_url((string)($v['download']['url'] ?? ''), PHP_URL_PATH) ?: '');
|
||||||
|
if ($entryFilename !== $expectedFilename) continue;
|
||||||
|
}
|
||||||
|
$entry = $v;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (!$entry) {
|
if (!$entry) {
|
||||||
Response::error('version_not_found', "Version {$version} absente du manifest", 404);
|
$suffix = $expectedFilename !== '' ? " (filename attendu : {$expectedFilename})" : '';
|
||||||
|
Response::error('version_not_found', "Version {$version} absente du manifest{$suffix}", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérif droits téléchargement
|
// Vérif droits téléchargement
|
||||||
@@ -95,6 +120,46 @@ final class DownloadUrl
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extrait le NOM DE FICHIER depuis l'URL du manifest (= source de vérité).
|
||||||
|
// Avant : on utilisait un template hardcodé `proserve-{version}.zip`,
|
||||||
|
// ce qui ignorait toute personnalisation du nom (ex. opérateur qui rename
|
||||||
|
// en `proserve-full-1.5.4.zip` pour buster un cache CDN). Conséquence :
|
||||||
|
// l'endpoint retournait une URL signée pointant vers un fichier inexistant
|
||||||
|
// → 404 systématique côté client sans aucune indication serveur.
|
||||||
|
// Maintenant : on lit `download.url` du manifest, on extrait le filename
|
||||||
|
// via parse_url + basename, on vérifie qu'il existe physiquement dans
|
||||||
|
// /builds/, et seulement après on signe.
|
||||||
|
$manifestUrl = $entry['download']['url'] ?? '';
|
||||||
|
if ($manifestUrl === '') {
|
||||||
|
Response::error('manifest_incomplete',
|
||||||
|
"L'entrée manifest pour v{$version} est sans download.url", 500);
|
||||||
|
}
|
||||||
|
$parsedUrl = parse_url($manifestUrl);
|
||||||
|
$urlPath = $parsedUrl['path'] ?? '';
|
||||||
|
$filename = basename($urlPath);
|
||||||
|
// Whitelist défensive sur le filename : caractères safe + suffixe .zip.
|
||||||
|
// Évite path traversal et autres injections via un manifest corrompu.
|
||||||
|
if ($filename === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $filename)) {
|
||||||
|
Response::error('manifest_invalid_filename',
|
||||||
|
"Nom de fichier invalide extrait du manifest pour v{$version} : '{$filename}'", 500);
|
||||||
|
}
|
||||||
|
// Vérifie que le fichier existe physiquement avant de signer une URL morte.
|
||||||
|
// Cas concret de bug remonté côté client : l'opérateur rename le ZIP dans
|
||||||
|
// /builds/ mais oublie de mettre à jour versions.json (ou inverse). Au
|
||||||
|
// lieu d'envoyer le client en 404 silencieux, on retourne une erreur
|
||||||
|
// serveur claire qui apparaît dans les logs PHP + client.
|
||||||
|
$buildsDir = dirname(__DIR__, 2) . '/builds';
|
||||||
|
$physicalPath = $buildsDir . '/' . $filename;
|
||||||
|
if (!is_file($physicalPath)) {
|
||||||
|
Response::error('file_missing',
|
||||||
|
"Le fichier ZIP « {$filename} » référencé par le manifest pour v{$version} est absent du dossier /builds/. Vérifie que le manifest et le filesystem sont synchros.",
|
||||||
|
500,
|
||||||
|
[
|
||||||
|
'manifestUrl' => $manifestUrl,
|
||||||
|
'expectedPath' => $physicalPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Génère l'URL HMAC-signée
|
// Génère l'URL HMAC-signée
|
||||||
// TTL : 6 h. Compromis entre :
|
// TTL : 6 h. Compromis entre :
|
||||||
// - sécurité (limite la fenêtre de replay si une URL fuit)
|
// - sécurité (limite la fenêtre de replay si une URL fuit)
|
||||||
@@ -102,7 +167,7 @@ final class DownloadUrl
|
|||||||
// Pour une connexion plus lente, le client sait auto-refresher l'URL
|
// Pour une connexion plus lente, le client sait auto-refresher l'URL
|
||||||
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
|
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
|
||||||
$baseUrl = rtrim($config['base_url'], '/');
|
$baseUrl = rtrim($config['base_url'], '/');
|
||||||
$relPath = '/builds/proserve-' . $version . '.zip';
|
$relPath = '/builds/' . $filename;
|
||||||
$exp = time() + 21600; // 6 h
|
$exp = time() + 21600; // 6 h
|
||||||
$secret = $config['hmac_secret'] ?? '';
|
$secret = $config['hmac_secret'] ?? '';
|
||||||
if ($secret === '') {
|
if ($secret === '') {
|
||||||
|
|||||||
@@ -40,9 +40,17 @@ final class Manifest
|
|||||||
$clientChannel = null;
|
$clientChannel = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi-channel opt-in : les clients v1.0.10+ savent afficher plusieurs
|
||||||
|
// entries au même numéro de version (channels distincts). Ils passent
|
||||||
|
// ?multiChannel=1 pour signaler la capabilité. Les clients v1.0.9- (et
|
||||||
|
// avant refactor row-by-Id) crasheraient sur ToDictionary(v.Version) →
|
||||||
|
// le serveur reste en mode dédup pour eux (comportement historique).
|
||||||
|
$multiChannel = !empty($_GET['multiChannel']);
|
||||||
|
|
||||||
$manifest['versions'] = self::filterVersions(
|
$manifest['versions'] = self::filterVersions(
|
||||||
$manifest['versions'] ?? [],
|
$manifest['versions'] ?? [],
|
||||||
$clientChannel
|
$clientChannel,
|
||||||
|
$multiChannel
|
||||||
);
|
);
|
||||||
|
|
||||||
// Re-signature à la volée. La clé privée Ed25519 est dans config.php
|
// Re-signature à la volée. La clé privée Ed25519 est dans config.php
|
||||||
@@ -85,7 +93,7 @@ final class Manifest
|
|||||||
* @param array<int, array<string,mixed>> $versions
|
* @param array<int, array<string,mixed>> $versions
|
||||||
* @return list<array<string,mixed>>
|
* @return list<array<string,mixed>>
|
||||||
*/
|
*/
|
||||||
private static function filterVersions(array $versions, ?string $clientChannel): array
|
private static function filterVersions(array $versions, ?string $clientChannel, bool $multiChannel = false): array
|
||||||
{
|
{
|
||||||
// Étape 1 : filter visible par ce client
|
// Étape 1 : filter visible par ce client
|
||||||
$visible = [];
|
$visible = [];
|
||||||
@@ -102,7 +110,18 @@ final class Manifest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Étape 2 : group by version, pick most specific per group
|
// Client v1.0.10+ : bypass la dédup, retourne toutes les entries visibles.
|
||||||
|
// Le client sait maintenant afficher plusieurs rows au même numéro (keyage
|
||||||
|
// par entryId côté RebuildList) et bloque les collisions d'install côté
|
||||||
|
// guard. Le badge channel n'apparaît que si plusieurs entries partagent
|
||||||
|
// le numéro (v1.0.9+) → UX propre.
|
||||||
|
if ($multiChannel) {
|
||||||
|
return array_map(fn($item) => $item['entry'], $visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Étape 2 (clients v1.0.9-) : group by version, pick most specific per
|
||||||
|
// group. Comportement historique pour ne pas crasher les vieux clients
|
||||||
|
// qui font ToDictionary(v.Version) et exploseraient sur des duplicates.
|
||||||
$byVersion = [];
|
$byVersion = [];
|
||||||
foreach ($visible as $item) {
|
foreach ($visible as $item) {
|
||||||
$key = (string)($item['entry']['version'] ?? '?');
|
$key = (string)($item['entry']['version'] ?? '?');
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ final class Releasenotes
|
|||||||
public static function handle(string $key): void
|
public static function handle(string $key): void
|
||||||
{
|
{
|
||||||
$isEntryId = (bool)preg_match('/^v[0-9a-f]{8}$/', $key);
|
$isEntryId = (bool)preg_match('/^v[0-9a-f]{8}$/', $key);
|
||||||
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $key);
|
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||||
|
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $key);
|
||||||
|
|
||||||
if (!$isEntryId && !$isVersion) {
|
if (!$isEntryId && !$isVersion) {
|
||||||
Response::error('invalid_id', 'Bad release-notes identifier', 400);
|
Response::error('invalid_id', 'Bad release-notes identifier', 400);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
DELIMITER $$
|
|
||||||
CREATE PROCEDURE ps_launcher_migrate_002()
|
|
||||||
BEGIN
|
|
||||||
-- channel
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'licenses' AND COLUMN_NAME = 'channel'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE licenses ADD COLUMN channel VARCHAR(64) NULL AFTER max_machines;
|
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;
|
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);
|
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.
|
||||||
18
server/migrations/004_license_contact_emails.sql
Normal file
18
server/migrations/004_license_contact_emails.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- PS_Launcher schema v4
|
||||||
|
-- Ajoute contact_emails sur la table licenses : liste d'adresses séparées
|
||||||
|
-- par virgules ou retours-ligne (parsing tolérant côté admin), à utiliser
|
||||||
|
-- pour notifier les clients quand une nouvelle version PROSERVE est publiée.
|
||||||
|
--
|
||||||
|
-- Format de stockage : TEXT brut (pas JSON) — l'admin saisit librement, le
|
||||||
|
-- backend split sur [,;\n\r\s] et filtre par regex email avant l'envoi.
|
||||||
|
-- Pourquoi pas une table normalisée license_contacts(license_id, email) :
|
||||||
|
-- overkill pour une feature de notification opportuniste sans relations.
|
||||||
|
--
|
||||||
|
-- À jouer après 001_init.sql, 002_channel_betas.sql, 003_settings_lock.sql.
|
||||||
|
-- migrate.php attrape "Duplicate column" comme idempotent.
|
||||||
|
|
||||||
|
ALTER TABLE licenses ADD COLUMN contact_emails TEXT NULL AFTER settings_lock_password_hash;
|
||||||
|
|
||||||
|
-- Note : les licenses existantes ont contact_emails = NULL → aucun email
|
||||||
|
-- à notifier → comportement identique à avant. L'admin doit explicitement
|
||||||
|
-- attribuer les contacts via le modal d'édition license, onglet « Contacts ».
|
||||||
11
server/migrations/005_license_language.sql
Normal file
11
server/migrations/005_license_language.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- PS_Launcher schema v5
|
||||||
|
-- Ajoute language sur la table licenses : code de langue 2-letter (fr/en/zh/th/ar)
|
||||||
|
-- utilisé pour localiser les emails de notification de release. NULL = English
|
||||||
|
-- (defaut conservateur pour les licenses existantes qui n'ont pas été éditées).
|
||||||
|
-- Le contenu de la release note elle-même reste TOUJOURS en anglais — seul le
|
||||||
|
-- texte d'accompagnement (greeting, instructions, CTAs) est localisé.
|
||||||
|
--
|
||||||
|
-- À jouer après 004_license_contact_emails.sql.
|
||||||
|
-- migrate.php attrape "Duplicate column" comme idempotent.
|
||||||
|
|
||||||
|
ALTER TABLE licenses ADD COLUMN language VARCHAR(8) NULL AFTER contact_emails;
|
||||||
51
server/releasenotes/1.5.4.md
Normal file
51
server/releasenotes/1.5.4.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# PROSERVE v1.5.4
|
||||||
|
|
||||||
|
Built on **Unreal Engine 5.7** — major visual fidelity and runtime
|
||||||
|
performance improvements across all scenarios.
|
||||||
|
|
||||||
|
This release is packed with new tooling and a major rework of our AI
|
||||||
|
systems. Here's what's new:
|
||||||
|
|
||||||
|
## ✨ New features
|
||||||
|
|
||||||
|
- **PROSERVE Editor (v0.5 Alpha)** — first integrated editor for
|
||||||
|
building and tweaking scenarios directly inside PROSERVE.
|
||||||
|
*Still alpha — feedback welcome.*
|
||||||
|
|
||||||
|
- **AI Autonomous Agent — full rework** — smarter decision-making,
|
||||||
|
more believable behaviors, smoother pathfinding and reactions.
|
||||||
|
|
||||||
|
- **Conversational AI Agent (Alpha)** — talk to NPCs in natural
|
||||||
|
language. First iteration, expect rapid improvements over the
|
||||||
|
upcoming releases.
|
||||||
|
|
||||||
|
- **PS_Launcher** — new companion tool that simplifies version
|
||||||
|
updates and the launch of PROSERVE on each workstation. Auto-detects
|
||||||
|
new releases, handles install + redists, and surfaces stats and
|
||||||
|
documentation in one place.
|
||||||
|
Download: <https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe>
|
||||||
|
|
||||||
|
- **PS_Report** — view, export, and print your mission results and
|
||||||
|
trainee statistics directly from the launcher.
|
||||||
|
|
||||||
|
## 🚀 Gameplay improvements
|
||||||
|
|
||||||
|
- **Aiming** — reduced reaction time and improved shooting precision
|
||||||
|
across all firearms.
|
||||||
|
|
||||||
|
- **Character gaze** — characters now realistically track the
|
||||||
|
trainee and points of interest, adding presence and immersion to
|
||||||
|
every interaction.
|
||||||
|
|
||||||
|
- **Hose behavior (Firefighter edition)** — reworked physics and
|
||||||
|
grip feel for the firehose; more predictable, less jittery, more
|
||||||
|
satisfying to handle.
|
||||||
|
|
||||||
|
## 🌍 Localization
|
||||||
|
|
||||||
|
- **Thai language** added — full UI translation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*As always, your feedback is invaluable. If you spot any issue or
|
||||||
|
have suggestions, reach out to your ASTERION VR account manager.*
|
||||||
@@ -56,16 +56,26 @@ final class SignManifest
|
|||||||
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
|
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
|
||||||
* ce qui permet de garder le cache à jour sans intervention manuelle.
|
* ce qui permet de garder le cache à jour sans intervention manuelle.
|
||||||
*
|
*
|
||||||
|
* IMPORTANT : si <code>$force</code> est true, on IGNORE le cache et on
|
||||||
|
* recalcule (puis on rafraîchit le cache). Sans ça, le cache interne
|
||||||
|
* supplantait silencieusement le `--force` du caller — bug subtil parce
|
||||||
|
* que la couche outer de `run()` avait déjà skip le cache (sha REPLACE
|
||||||
|
* ou force=true), mais ce helper retombait sur sa propre cache lookup
|
||||||
|
* (clé = realpath) → retournait instantanément l'ancien hash sans
|
||||||
|
* recalculer, même après que l'opérateur ait re-uploadé le ZIP via
|
||||||
|
* SFTP avec mtime préservé.
|
||||||
|
*
|
||||||
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
|
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
|
||||||
* @return array{sha256:string, fromCache:bool, durationMs:int}
|
* @return array{sha256:string, fromCache:bool, durationMs:int}
|
||||||
*/
|
*/
|
||||||
private function getOrComputeSha256(string $path, array &$cache): array
|
private function getOrComputeSha256(string $path, array &$cache, bool $force = false): array
|
||||||
{
|
{
|
||||||
$size = filesize($path) ?: 0;
|
$size = filesize($path) ?: 0;
|
||||||
$mtime = filemtime($path) ?: 0;
|
$mtime = filemtime($path) ?: 0;
|
||||||
$key = realpath($path) ?: $path;
|
$key = realpath($path) ?: $path;
|
||||||
|
|
||||||
if (isset($cache[$key])
|
if (!$force
|
||||||
|
&& isset($cache[$key])
|
||||||
&& ($cache[$key]['size'] ?? -1) === $size
|
&& ($cache[$key]['size'] ?? -1) === $size
|
||||||
&& ($cache[$key]['mtime'] ?? -1) === $mtime
|
&& ($cache[$key]['mtime'] ?? -1) === $mtime
|
||||||
&& !empty($cache[$key]['sha256'])) {
|
&& !empty($cache[$key]['sha256'])) {
|
||||||
@@ -73,7 +83,37 @@ final class SignManifest
|
|||||||
}
|
}
|
||||||
|
|
||||||
$start = microtime(true);
|
$start = microtime(true);
|
||||||
$sha = hash_file('sha256', $path);
|
// Streamed hash en chunks 16 Mo. Deux bénéfices vs hash_file() atomique :
|
||||||
|
// 1. On peut envoyer un heartbeat (flush()) entre chunks pour que le
|
||||||
|
// proxy Apache/OVH ne timeout pas la requête pendant les ~5-10 min
|
||||||
|
// que prend un SHA-256 sur un ZIP de 14 Go via SAN mutualisé.
|
||||||
|
// 2. On peut relever set_time_limit() à chaque chunk (fenêtre glissante)
|
||||||
|
// au lieu de faire un unique set_time_limit(0) à la caller.
|
||||||
|
// Mémoire : hash_update_stream() ne buffere pas — c'est du streaming pur.
|
||||||
|
$fp = @fopen($path, 'rb');
|
||||||
|
if ($fp === false) {
|
||||||
|
return ['sha256' => '', 'fromCache' => false, 'durationMs' => 0];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$ctx = hash_init('sha256');
|
||||||
|
// 16 Mo = compromis entre nombre d'appels PHP et pression CPU par read()
|
||||||
|
$chunkBytes = 16 * 1024 * 1024;
|
||||||
|
while (!feof($fp)) {
|
||||||
|
hash_update_stream($ctx, $fp, $chunkBytes);
|
||||||
|
// Fenêtre glissante : autorise ~5 min de plus avant que PHP ne
|
||||||
|
// timeout. Sur un fichier de 14 Go / chunks 16 Mo = ~900 itérations,
|
||||||
|
// donc si un chunk prend >5 min c'est vraiment que le disque est HS.
|
||||||
|
@set_time_limit(300);
|
||||||
|
// Heartbeat côté front — évite Apache RequestTimeout / OVH proxy
|
||||||
|
// timeout sur les grosses requêtes. Silencieux si output buffering
|
||||||
|
// est actif (pas fatal).
|
||||||
|
@ob_flush();
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
$sha = hash_final($ctx);
|
||||||
|
} finally {
|
||||||
|
fclose($fp);
|
||||||
|
}
|
||||||
$duration = (int)((microtime(true) - $start) * 1000);
|
$duration = (int)((microtime(true) - $start) * 1000);
|
||||||
|
|
||||||
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha];
|
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha];
|
||||||
@@ -99,11 +139,57 @@ final class SignManifest
|
|||||||
* @param bool $force Si true, ignore le cache et recalcule tous les hashs
|
* @param bool $force Si true, ignore le cache et recalcule tous les hashs
|
||||||
* @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
|
* @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
|
||||||
* versions[] (les autres restent inchangées). Le scope 'launcher'
|
* versions[] (les autres restent inchangées). Le scope 'launcher'
|
||||||
* est ignoré dans ce cas.
|
* est ignoré dans ce cas. ATTENTION : si plusieurs entrées
|
||||||
|
* partagent le même numéro de version (channels firefighter
|
||||||
|
* vs full sur 1.5.4.32 p.ex.), TOUTES sont hashées — d'où
|
||||||
|
* $onlyEntryId ci-dessous pour cibler une seule ligne.
|
||||||
|
* @param ?string $onlyEntryId Si non null, ne touche QUE l'entrée avec cet id (généré par
|
||||||
|
* generate_entry_id() au backoffice). Prend le pas sur
|
||||||
|
* $onlyVersion. Utilisé par le bouton « 🔁 Hash » d'une
|
||||||
|
* ligne isolée pour éviter de re-hasher les autres channels
|
||||||
|
* qui partagent le même numéro de version (2 × 14 Go dans une
|
||||||
|
* requête HTTP → risque de timeout front OVH).
|
||||||
* @return array{ok:bool, log:string[]}
|
* @return array{ok:bool, log:string[]}
|
||||||
*/
|
*/
|
||||||
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null): array
|
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null, ?string $onlyEntryId = null): array
|
||||||
{
|
{
|
||||||
|
// Sur OVH mutualisé, un SHA-256 d'un ZIP de 14 Go peut prendre 5-10 min via
|
||||||
|
// le SAN partagé. Le max_execution_time par défaut (30-60s) tue le process
|
||||||
|
// → Apache retourne 500 Internal Server Error avec le boilerplate
|
||||||
|
// postmaster@… — c'est le mode d'échec principal du bouton « Hash » au
|
||||||
|
// backoffice. On désactive la limite ici (couvre AUSSI les callers CLI et
|
||||||
|
// cron, pas seulement l'admin web). ignore_user_abort évite qu'un refresh
|
||||||
|
// ou une fermeture d'onglet côté opérateur interrompe un hash en cours.
|
||||||
|
@set_time_limit(0);
|
||||||
|
@ignore_user_abort(true);
|
||||||
|
|
||||||
|
// Capture toutes les erreurs PHP (warnings + fatals + exceptions non-catchées)
|
||||||
|
// dans un fichier accessible via SFTP, à côté du manifest. Sur OVH mutualisé
|
||||||
|
// les logs Apache ne sont accessibles que via le manager web — pas pratique
|
||||||
|
// pour un diagnostic rapide de 500. Ce fichier permet à l'opérateur de le
|
||||||
|
// grep post-clic sans passer par le manager.
|
||||||
|
$errorLogPath = dirname($this->manifestPath) . '/.signmanifest-error.log';
|
||||||
|
@ini_set('log_errors', '1');
|
||||||
|
@ini_set('error_log', $errorLogPath);
|
||||||
|
@error_reporting(E_ALL);
|
||||||
|
$ts = date('Y-m-d H:i:s');
|
||||||
|
@file_put_contents($errorLogPath,
|
||||||
|
"[{$ts}] --- SignManifest::run(scope={$scope}, force=" . ($force?'1':'0')
|
||||||
|
. ", onlyVersion=" . ($onlyVersion ?? 'null')
|
||||||
|
. ", onlyEntryId=" . ($onlyEntryId ?? 'null') . ") ---\n",
|
||||||
|
FILE_APPEND);
|
||||||
|
// Fatal errors → capturés par un shutdown handler. Sinon Apache renvoie
|
||||||
|
// juste 500 sans qu'on sache ce qui a claqué.
|
||||||
|
register_shutdown_function(function () use ($errorLogPath) {
|
||||||
|
$err = error_get_last();
|
||||||
|
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
|
||||||
|
@file_put_contents($errorLogPath,
|
||||||
|
"[" . date('Y-m-d H:i:s') . "] FATAL " . $err['type']
|
||||||
|
. " : {$err['message']} @ {$err['file']}:{$err['line']}\n",
|
||||||
|
FILE_APPEND);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!is_file($this->manifestPath)) {
|
if (!is_file($this->manifestPath)) {
|
||||||
$this->out("Manifest introuvable : {$this->manifestPath}");
|
$this->out("Manifest introuvable : {$this->manifestPath}");
|
||||||
return ['ok' => false, 'log' => $this->log];
|
return ['ok' => false, 'log' => $this->log];
|
||||||
@@ -117,9 +203,9 @@ final class SignManifest
|
|||||||
|
|
||||||
$doVersions = ($scope === 'all' || $scope === 'versions');
|
$doVersions = ($scope === 'all' || $scope === 'versions');
|
||||||
$doLauncher = ($scope === 'all' || $scope === 'launcher');
|
$doLauncher = ($scope === 'all' || $scope === 'launcher');
|
||||||
if ($onlyVersion !== null) {
|
if ($onlyVersion !== null || $onlyEntryId !== null) {
|
||||||
// Mode "hash une seule version" : on ne touche pas au launcher,
|
// Mode "hash une seule entrée / version" : on ne touche pas au launcher,
|
||||||
// et on ne hash que la version demandée dans la section versions[].
|
// et on ne hash que ce qui correspond au filtre demandé dans versions[].
|
||||||
$doLauncher = false;
|
$doLauncher = false;
|
||||||
$doVersions = true;
|
$doVersions = true;
|
||||||
}
|
}
|
||||||
@@ -131,7 +217,15 @@ final class SignManifest
|
|||||||
$hashedVersions = [];
|
$hashedVersions = [];
|
||||||
if ($doVersions) foreach ($manifest['versions'] as &$v) {
|
if ($doVersions) foreach ($manifest['versions'] as &$v) {
|
||||||
$version = $v['version'] ?? '?';
|
$version = $v['version'] ?? '?';
|
||||||
if ($onlyVersion !== null && $version !== $onlyVersion) {
|
$entryId = (string)($v['id'] ?? '');
|
||||||
|
// Filtre par id d'entrée prioritaire sur filtre par version (cas
|
||||||
|
// channels multiples partageant un numéro de version identique —
|
||||||
|
// ex : proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32).
|
||||||
|
// Sinon, filtre par numéro de version (rétro-compat).
|
||||||
|
if ($onlyEntryId !== null && $entryId !== $onlyEntryId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($onlyEntryId === null && $onlyVersion !== null && $version !== $onlyVersion) {
|
||||||
continue; // skip silently les autres versions
|
continue; // skip silently les autres versions
|
||||||
}
|
}
|
||||||
$url = $v['download']['url'] ?? '';
|
$url = $v['download']['url'] ?? '';
|
||||||
@@ -192,7 +286,26 @@ final class SignManifest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$r = $this->getOrComputeSha256($zip, $cache);
|
// Log préalable au hash — utile pour identifier QUEL ZIP a fait
|
||||||
|
// planter le process si on retombe sur le 500. Sans ça, l'error log
|
||||||
|
// dit juste "PHP Fatal…" sans savoir si c'est le 1er ou le Nième ZIP.
|
||||||
|
@file_put_contents($errorLogPath,
|
||||||
|
"[" . date('Y-m-d H:i:s') . "] START hash $version → $zip ($size octets)\n",
|
||||||
|
FILE_APPEND);
|
||||||
|
try {
|
||||||
|
$r = $this->getOrComputeSha256($zip, $cache, $force);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
@file_put_contents($errorLogPath,
|
||||||
|
"[" . date('Y-m-d H:i:s') . "] EXCEPTION during hash of $version : "
|
||||||
|
. get_class($e) . " : {$e->getMessage()} @ {$e->getFile()}:{$e->getLine()}\n"
|
||||||
|
. $e->getTraceAsString() . "\n",
|
||||||
|
FILE_APPEND);
|
||||||
|
$this->out(" [ERROR] $version : hash failed — " . $e->getMessage());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
@file_put_contents($errorLogPath,
|
||||||
|
"[" . date('Y-m-d H:i:s') . "] DONE hash $version → sha256={$r['sha256']} ({$r['durationMs']} ms, fromCache=" . ($r['fromCache']?'1':'0') . ")\n",
|
||||||
|
FILE_APPEND);
|
||||||
$cacheChanged = true;
|
$cacheChanged = true;
|
||||||
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||||||
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
|
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
|
||||||
@@ -231,7 +344,7 @@ final class SignManifest
|
|||||||
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
|
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
|
||||||
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
|
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
|
||||||
} else {
|
} else {
|
||||||
$r = $this->getOrComputeSha256($lpath, $cache);
|
$r = $this->getOrComputeSha256($lpath, $cache, $force);
|
||||||
$cacheChanged = true;
|
$cacheChanged = true;
|
||||||
$launcher['download']['sha256'] = $r['sha256'];
|
$launcher['download']['sha256'] = $r['sha256'];
|
||||||
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||||||
|
|||||||
@@ -70,13 +70,42 @@ foreach ($files as $f) {
|
|||||||
// Découpe naïve sur les ';' suivis d'un saut de ligne (suffit pour notre schéma)
|
// Découpe naïve sur les ';' suivis d'un saut de ligne (suffit pour notre schéma)
|
||||||
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: []));
|
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: []));
|
||||||
foreach ($statements as $stmt) {
|
foreach ($statements as $stmt) {
|
||||||
if ($stmt === '' || str_starts_with($stmt, '--')) continue;
|
// Strip les lignes de commentaire SQL (--) du début / intercalées avant
|
||||||
|
// de checker si le statement est vide. Sinon : un fichier migration qui
|
||||||
|
// commence par un header de commentaires se retrouve dans le PREMIER
|
||||||
|
// chunk avec son premier ALTER (séparés par \n pas par ;\n) ; le chunk
|
||||||
|
// commence donc par `--`, le check str_starts_with le déclare vide,
|
||||||
|
// et l'ALTER est silencieusement skip. Bug rapporté : migrations 003+
|
||||||
|
// qui n'ont qu'un seul ALTER après un bloc de commentaires en tête
|
||||||
|
// n'étaient jamais appliquées (output vide entre "--- 003 ---" et "===").
|
||||||
|
$lines = preg_split('/\r?\n/', $stmt) ?: [];
|
||||||
|
$kept = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$t = ltrim($line);
|
||||||
|
if ($t === '' || str_starts_with($t, '--')) continue;
|
||||||
|
$kept[] = $line;
|
||||||
|
}
|
||||||
|
$stmt = trim(implode("\n", $kept));
|
||||||
|
if ($stmt === '') continue;
|
||||||
try {
|
try {
|
||||||
$pdo->exec($stmt);
|
$pdo->exec($stmt);
|
||||||
$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;
|
||||||
@@ -24,6 +25,7 @@ using PSLauncher.Core.Health.OpenVRInterop;
|
|||||||
using PSLauncher.Core.Migrations;
|
using PSLauncher.Core.Migrations;
|
||||||
using PSLauncher.Core.Process;
|
using PSLauncher.Core.Process;
|
||||||
using PSLauncher.Core.ReportTool;
|
using PSLauncher.Core.ReportTool;
|
||||||
|
using PSLauncher.Core.SteamVr;
|
||||||
using PSLauncher.Core.Updates;
|
using PSLauncher.Core.Updates;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -33,6 +35,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";
|
||||||
@@ -111,6 +120,19 @@ public partial class App : Application
|
|||||||
|
|
||||||
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
|
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
|
||||||
|
|
||||||
|
// RENDER MODE : sur certains environnements (RDP, VMs sans hardware accel,
|
||||||
|
// GPU émulé, Intel HD anciens, Windows Server sans Desktop Experience…)
|
||||||
|
// l'accélération matérielle WPF plante en silence : la fenêtre s'affiche
|
||||||
|
// toute blanche, aucun contenu ne rend, aucune exception ne remonte. C'est
|
||||||
|
// un bug classique non rattrapable côté .NET parce que le pipeline graphique
|
||||||
|
// tombe entre les mailles du try/catch. On force le rendu software dans 3 cas :
|
||||||
|
// 1. RenderCapability.Tier == 0 (WPF lui-même détecte aucune accel possible)
|
||||||
|
// 2. Session Bureau à distance (RDP) détectée
|
||||||
|
// 3. Var d'env PSLAUNCHER_SOFTWARE_RENDER=1 (override manuel pour debug)
|
||||||
|
// Trade-off : rendu software plus lent (~30% impact sur le scrolling de la
|
||||||
|
// liste des versions), mais au moins ça affiche QUELQUE CHOSE.
|
||||||
|
ApplyRenderModeFallback();
|
||||||
|
|
||||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||||
{
|
{
|
||||||
// CRITIQUE : on flush AVANT que le process meure. Sans ça, sur les
|
// CRITIQUE : on flush AVANT que le process meure. Sans ça, sur les
|
||||||
@@ -175,6 +197,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 +209,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 +222,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>();
|
||||||
@@ -234,6 +259,15 @@ public partial class App : Application
|
|||||||
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
|
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
|
||||||
sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
|
sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
|
||||||
|
|
||||||
|
// Merge des settings SteamVR : si _steamvr/steamvr.vrsettings est
|
||||||
|
// présent dans le ZIP, tue SteamVR + Vive Business Streaming et
|
||||||
|
// fusionne les blocs racine dans le fichier de l'utilisateur
|
||||||
|
// (replace si existe, ajoute sinon). Voir SteamVrSettingsDeployer.
|
||||||
|
services.AddSingleton<ISteamVrSettingsDeployer>(sp =>
|
||||||
|
new SteamVrSettingsDeployer(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().SteamVr,
|
||||||
|
sp.GetRequiredService<ILogger<SteamVrSettingsDeployer>>()));
|
||||||
|
|
||||||
// Cache LAN P2P : permet aux PCs du LAN de partager les ZIPs déjà
|
// Cache LAN P2P : permet aux PCs du LAN de partager les ZIPs déjà
|
||||||
// téléchargés au lieu de re-télécharger 14 Go depuis OVH.
|
// téléchargés au lieu de re-télécharger 14 Go depuis OVH.
|
||||||
// - ZipCacheStore : couche de persistence (downloads + sidecar SHA)
|
// - ZipCacheStore : couche de persistence (downloads + sidecar SHA)
|
||||||
@@ -296,6 +330,55 @@ public partial class App : Application
|
|||||||
window.Show();
|
window.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Décide entre rendu hardware (par défaut WPF) et rendu software, en fonction
|
||||||
|
/// de l'environnement détecté + override par variable d'environnement. Log
|
||||||
|
/// la décision pour faciliter le debug si l'utilisateur reporte un écran blanc.
|
||||||
|
/// </summary>
|
||||||
|
private static void ApplyRenderModeFallback()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Override manuel : PSLAUNCHER_SOFTWARE_RENDER=1 force le software.
|
||||||
|
// Le user peut le set dans les variables d'environnement Windows OU dans
|
||||||
|
// le raccourci .lnk (champ Cible : "cmd /c set PSLAUNCHER_SOFTWARE_RENDER=1 && start PS_Launcher.exe")
|
||||||
|
var envOverride = Environment.GetEnvironmentVariable("PSLAUNCHER_SOFTWARE_RENDER");
|
||||||
|
var manualSoftware = !string.IsNullOrEmpty(envOverride)
|
||||||
|
&& (envOverride == "1" || envOverride.Equals("true", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
// RenderCapability.Tier renvoie un int packed : (tier << 16). Tier 0 = pas
|
||||||
|
// d'accélération matérielle possible. Tier 1 = partielle. Tier 2 = complète.
|
||||||
|
var tier = System.Windows.Media.RenderCapability.Tier >> 16;
|
||||||
|
|
||||||
|
// GetSystemMetrics(SM_REMOTESESSION) = 1 si la session est une session RDP.
|
||||||
|
// SM_REMOTESESSION constant = 0x1000 (4096).
|
||||||
|
var isRdp = GetSystemMetrics(0x1000) != 0;
|
||||||
|
|
||||||
|
var shouldUseSoftware = manualSoftware || tier == 0 || isRdp;
|
||||||
|
|
||||||
|
Log.Information("Render env : Tier={Tier} RDP={Rdp} EnvOverride={EnvOverride} → SoftwareOnly={Sw}",
|
||||||
|
tier, isRdp, manualSoftware, shouldUseSoftware);
|
||||||
|
|
||||||
|
if (shouldUseSoftware)
|
||||||
|
{
|
||||||
|
System.Windows.Media.RenderOptions.ProcessRenderMode =
|
||||||
|
System.Windows.Interop.RenderMode.SoftwareOnly;
|
||||||
|
Log.Warning("WPF render mode forced to SoftwareOnly (cause: " +
|
||||||
|
(manualSoftware ? "env override" : tier == 0 ? "Tier=0 no GPU accel" : "RDP session") + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Pas critique : si le fallback échoue, on continue avec le default
|
||||||
|
// hardware accel et on espère que ça marche. Le user verra un écran
|
||||||
|
// blanc si non, mais au moins l'app aura pas crashé.
|
||||||
|
Log.Warning(ex, "Failed to evaluate render-mode fallback, keeping default");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern int GetSystemMetrics(int nIndex);
|
||||||
|
|
||||||
protected override void OnExit(ExitEventArgs e)
|
protected override void OnExit(ExitEventArgs e)
|
||||||
{
|
{
|
||||||
Log.Information("PSLauncher shutting down");
|
Log.Information("PSLauncher shutting down");
|
||||||
|
|||||||
@@ -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.2</Version>
|
<Version>1.0.13</Version>
|
||||||
<AssemblyVersion>0.27.2.0</AssemblyVersion>
|
<AssemblyVersion>1.0.13.0</AssemblyVersion>
|
||||||
<FileVersion>0.27.2.0</FileVersion>
|
<FileVersion>1.0.13.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>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
Le runtime est pré-installé sur Win11 et auto-mis-à-jour via Windows
|
Le runtime est pré-installé sur Win11 et auto-mis-à-jour via Windows
|
||||||
Update sur Win10 récent. Si absent, le contrôle affiche une erreur
|
Update sur Win10 récent. Si absent, le contrôle affiche une erreur
|
||||||
et on aiguille l'user vers https://go.microsoft.com/fwlink/p/?LinkId=2124703 -->
|
et on aiguille l'user vers https://go.microsoft.com/fwlink/p/?LinkId=2124703 -->
|
||||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2792.45" />
|
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3800.47" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -102,6 +102,19 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
= new();
|
= new();
|
||||||
public bool HasApiBackups => ApiBackups.Count > 0;
|
public bool HasApiBackups => ApiBackups.Count > 0;
|
||||||
|
|
||||||
|
// ----- SteamVR settings merge -----
|
||||||
|
[ObservableProperty] private bool _steamVrAutoMerge;
|
||||||
|
[ObservableProperty] private string _steamVrSettingsPathOverride = string.Empty;
|
||||||
|
/// <summary>Multi-line text bindé sur un TextBox : 1 process name par ligne (sans .exe).</summary>
|
||||||
|
[ObservableProperty] private string _steamVrProcessesToKill = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Path canonique attendu si Steam est installé à l'emplacement par défaut.
|
||||||
|
/// Affiché sous le TextBox d'override pour que l'opérateur sache à quoi ressemble
|
||||||
|
/// la résolution standard et n'ait pas à le deviner / chercher dans la doc.
|
||||||
|
/// </summary>
|
||||||
|
public string SteamVrCanonicalPath =>
|
||||||
|
PSLauncher.Core.SteamVr.SteamVrSettingsDeployer.CanonicalSettingsPath;
|
||||||
|
|
||||||
// ----- Cache LAN P2P -----
|
// ----- Cache LAN P2P -----
|
||||||
[ObservableProperty] private bool _lanServerEnabled;
|
[ObservableProperty] private bool _lanServerEnabled;
|
||||||
[ObservableProperty] private bool _lanClientEnabled;
|
[ObservableProperty] private bool _lanClientEnabled;
|
||||||
@@ -116,6 +129,19 @@ 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;
|
||||||
|
|
||||||
|
// ----- Args par défaut (chaque lancement) -----
|
||||||
|
/// <summary>Args édités dans la UI. Persiste vers <c>_config.DefaultLaunchArgs</c> au Save.</summary>
|
||||||
|
public System.Collections.ObjectModel.ObservableCollection<AutoModeArgRowViewModel> DefaultLaunchArgs { get; } = new();
|
||||||
|
|
||||||
|
// ----- 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 +274,22 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_apiAutoDeploy = config.ApiTool.AutoDeploy;
|
_apiAutoDeploy = config.ApiTool.AutoDeploy;
|
||||||
_apiMaxBackups = config.ApiTool.MaxBackups;
|
_apiMaxBackups = config.ApiTool.MaxBackups;
|
||||||
|
|
||||||
|
_steamVrAutoMerge = config.SteamVr.AutoMerge;
|
||||||
|
_steamVrSettingsPathOverride = config.SteamVr.SettingsFilePathOverride ?? string.Empty;
|
||||||
|
_steamVrProcessesToKill = string.Join(
|
||||||
|
Environment.NewLine,
|
||||||
|
config.SteamVr.ProcessesToKill ?? new List<string>());
|
||||||
|
|
||||||
|
foreach (var a in config.DefaultLaunchArgs ?? new List<AutoModeArg>())
|
||||||
|
DefaultLaunchArgs.Add(new AutoModeArgRowViewModel(a.Key, a.Value, RemoveDefaultLaunchArg));
|
||||||
|
|
||||||
|
_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 +455,46 @@ 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.SteamVr.AutoMerge = SteamVrAutoMerge;
|
||||||
|
_config.SteamVr.SettingsFilePathOverride = string.IsNullOrWhiteSpace(SteamVrSettingsPathOverride)
|
||||||
|
? null : SteamVrSettingsPathOverride.Trim();
|
||||||
|
_config.SteamVr.ProcessesToKill = (SteamVrProcessesToKill ?? string.Empty)
|
||||||
|
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(l =>
|
||||||
|
{
|
||||||
|
var name = l.Trim();
|
||||||
|
// Strip .exe défensif (case-insensitive) au cas où l'opérateur le tape.
|
||||||
|
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
name = name[..^4].Trim();
|
||||||
|
return name;
|
||||||
|
})
|
||||||
|
.Where(l => l.Length > 0)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_config.DefaultLaunchArgs = DefaultLaunchArgs
|
||||||
|
.Select(r => new AutoModeArg
|
||||||
|
{
|
||||||
|
Key = (r.Key ?? string.Empty).Trim(),
|
||||||
|
Value = string.IsNullOrEmpty(r.Value) ? null : r.Value.Trim(),
|
||||||
|
})
|
||||||
|
.Where(a => !string.IsNullOrWhiteSpace(a.Key))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_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 +1072,55 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
IsDeployingApi = false;
|
IsDeployingApi = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== DEFAULT LAUNCH ARGS =====================
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void AddDefaultLaunchArg()
|
||||||
|
{
|
||||||
|
DefaultLaunchArgs.Add(new AutoModeArgRowViewModel(string.Empty, null, RemoveDefaultLaunchArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveDefaultLaunchArg(AutoModeArgRowViewModel row)
|
||||||
|
{
|
||||||
|
DefaultLaunchArgs.Remove(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 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>
|
||||||
|
|||||||
@@ -26,6 +26,55 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
public VersionManifest? Remote { get; }
|
public VersionManifest? Remote { get; }
|
||||||
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
|
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clé stable qui identifie une row au-delà du simple <see cref="Version"/>,
|
||||||
|
/// pour distinguer deux entrées manifest partageant le même numéro sur des
|
||||||
|
/// channels différents (ex : proserve-firefighter-1.5.4.32 vs proserve-full-
|
||||||
|
/// 1.5.4.32). Priorité :
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Basename du dossier d'install (côté installed, ou côté remote
|
||||||
|
/// résolu via <c>InstallFolderTemplate</c>) — unique par convention car
|
||||||
|
/// deux entries au même version DOIVENT avoir des dossiers distincts
|
||||||
|
/// (validé backoffice + install guard client).</item>
|
||||||
|
/// <item>Fallback : le <see cref="Version"/> seul (mono-channel, comportement
|
||||||
|
/// identique à avant).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Utilisé pour identifier une row lors des lookups par état actif (DL en
|
||||||
|
/// cours, install courant), au lieu de matcher par <c>Version</c> qui devient
|
||||||
|
/// ambigu sur les setups multi-channels.
|
||||||
|
/// </summary>
|
||||||
|
public string RowKey { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Channel principal à afficher comme badge sur la row (null / vide → pas
|
||||||
|
/// de badge). Sélection : le premier channel non-« default » de
|
||||||
|
/// <see cref="VersionManifest.Channels"/>, en <c>OrdinalIgnoreCase</c>. Pour
|
||||||
|
/// une row installée sans remote (orpheline), c'est null. Pour une entrée
|
||||||
|
/// remote sans Channels ou uniquement « default », c'est null.
|
||||||
|
/// </summary>
|
||||||
|
public string? ChannelBadge => Remote?.Channels?
|
||||||
|
.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c)
|
||||||
|
&& !string.Equals(c, "default", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True si au moins une AUTRE row visible sur ce launcher partage le même
|
||||||
|
/// numéro de version. Positionné par <c>MainViewModel.RebuildList</c> après
|
||||||
|
/// construction des rows (group by Version, count >= 2 → conflict).
|
||||||
|
/// Le badge channel n'est utile QUE dans ce cas — sinon la row est
|
||||||
|
/// univoque et le badge ajoute du bruit visuel.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasChannelBadge))]
|
||||||
|
private bool _hasVersionConflict;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le badge n'est visible QUE quand la row a un channel spécifique ET qu'une
|
||||||
|
/// autre row visible partage le même numéro de version. Ça garde l'UI propre
|
||||||
|
/// sur les setups mono-channel (99 % des cas) et affiche le distingueur
|
||||||
|
/// seulement quand il est réellement nécessaire.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasChannelBadge => !string.IsNullOrEmpty(ChannelBadge) && HasVersionConflict;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True si la version est taggée BÊTA dans le manifest (champ
|
/// True si la version est taggée BÊTA dans le manifest (champ
|
||||||
/// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge
|
/// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge
|
||||||
@@ -71,6 +120,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;
|
||||||
|
|
||||||
@@ -159,6 +246,19 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
Installed = installed;
|
Installed = installed;
|
||||||
Remote = remote;
|
Remote = remote;
|
||||||
_state = initialState;
|
_state = initialState;
|
||||||
|
|
||||||
|
// RowKey — priorité : (1) Id de l'entrée manifest (unique par entry, survit
|
||||||
|
// à un rename de folder), (2) EntryId lu depuis le .proserve-meta.json de
|
||||||
|
// l'install, (3) folder name basename, (4) version en dernier recours.
|
||||||
|
// Doit matcher la logique de MainViewModel.RebuildList pour que les lookups
|
||||||
|
// par RowKey trouvent la row correcte.
|
||||||
|
var folderName = !string.IsNullOrEmpty(folderPath)
|
||||||
|
? System.IO.Path.GetFileName(folderPath)
|
||||||
|
: remote?.GetInstallFolderName();
|
||||||
|
RowKey = !string.IsNullOrEmpty(remote?.Id) ? remote!.Id!
|
||||||
|
: !string.IsNullOrEmpty(installed?.EntryId) ? installed!.EntryId!
|
||||||
|
: !string.IsNullOrWhiteSpace(folderName) ? folderName!
|
||||||
|
: version;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Les commandes sont câblées par le MainViewModel après instanciation
|
// Les commandes sont câblées par le MainViewModel après instanciation
|
||||||
@@ -170,6 +270,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
|
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
|
||||||
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
|
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
|
||||||
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
|
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
|
||||||
|
public Action<VersionRowViewModel>? ForceFreshDownloadHandler { get; set; }
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanLaunch))]
|
[RelayCommand(CanExecute = nameof(CanLaunch))]
|
||||||
private void Launch() => LaunchHandler?.Invoke(this);
|
private void Launch() => LaunchHandler?.Invoke(this);
|
||||||
@@ -193,6 +294,15 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
|
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
|
||||||
private bool CanRestartFromZero() => HasResumableDownload;
|
private bool CanRestartFromZero() => HasResumableDownload;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visible quand la row n'est pas en cours de DL (sinon le user devrait
|
||||||
|
/// d'abord cancel via le bouton dédié). Pas conditionnée par "partial existe"
|
||||||
|
/// car cette action a pour but spécifique de purger TOUT (cache + partial +
|
||||||
|
/// sidecar SHA), même quand il n'y a pas de partial en cours.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void ForceFreshDownload() => ForceFreshDownloadHandler?.Invoke(this);
|
||||||
|
|
||||||
private static string FormatSize(long bytes)
|
private static string FormatSize(long bytes)
|
||||||
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
|
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,20 @@
|
|||||||
FontSize="10" FontWeight="Bold"
|
FontSize="10" FontWeight="Bold"
|
||||||
Foreground="#1A0A00" />
|
Foreground="#1A0A00" />
|
||||||
</Border>
|
</Border>
|
||||||
|
<!-- Badge CHANNEL : violet, à côté des autres badges. Affiché
|
||||||
|
quand la row est sur un channel spécifique (non-default) et
|
||||||
|
qu'une autre row visible partage le même numéro de version.
|
||||||
|
Couleur violette #8B5CF6 pour se distinguer nettement du
|
||||||
|
badge BETA orange et du bleu du statut "Available". -->
|
||||||
|
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="#8B5CF6"
|
||||||
|
ToolTip="{Binding ChannelBadge}"
|
||||||
|
Visibility="{Binding HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<TextBlock Text="{Binding ChannelBadge}"
|
||||||
|
FontSize="10" FontWeight="Bold"
|
||||||
|
Foreground="White" />
|
||||||
|
</Border>
|
||||||
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
|
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
@@ -126,6 +140,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}"
|
||||||
@@ -173,6 +208,8 @@
|
|||||||
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
||||||
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
|
||||||
|
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
||||||
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
||||||
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
@@ -495,6 +532,20 @@
|
|||||||
FontSize="11" FontWeight="Bold"
|
FontSize="11" FontWeight="Bold"
|
||||||
Foreground="#1A0A00" />
|
Foreground="#1A0A00" />
|
||||||
</Border>
|
</Border>
|
||||||
|
<!-- Badge CHANNEL : pill violette. Visible sur les entrées
|
||||||
|
manifest taggées avec un channel non-default (firefighter,
|
||||||
|
police, etc.) quand une autre row partage le numéro de
|
||||||
|
version. Violet #8B5CF6 pour se distinguer du orange BETA
|
||||||
|
et du bleu du statut "Available". -->
|
||||||
|
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="#8B5CF6"
|
||||||
|
ToolTip="{Binding FeaturedVersion.ChannelBadge}"
|
||||||
|
Visibility="{Binding FeaturedVersion.HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<TextBlock Text="{Binding FeaturedVersion.ChannelBadge}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="White" />
|
||||||
|
</Border>
|
||||||
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
|
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
@@ -527,13 +578,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"
|
||||||
@@ -589,6 +661,8 @@
|
|||||||
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
||||||
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
|
||||||
|
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
||||||
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
||||||
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
|
|||||||
@@ -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,202 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- SteamVR : merge _steamvr/steamvr.vrsettings → fichier user à l'install -->
|
||||||
|
<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.SettingsSteamVr}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<CheckBox IsChecked="{Binding SteamVrAutoMerge}"
|
||||||
|
Margin="0,4,0,0"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsSteamVrAutoMerge}" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVrSettingsPath}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,16,0,4" TextWrapping="Wrap" />
|
||||||
|
<TextBox Text="{Binding SteamVrSettingsPathOverride, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
<!-- Affiche le path canonique attendu si Steam est à l'emplacement
|
||||||
|
par défaut. Évite à l'opérateur d'avoir à chercher dans la doc. -->
|
||||||
|
<TextBlock FontSize="11" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,4,0,0"
|
||||||
|
TextWrapping="Wrap">
|
||||||
|
<Run Text="Auto-détection standard : " />
|
||||||
|
<Run Text="{Binding SteamVrCanonicalPath, Mode=OneWay}"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVrProcesses}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,16,0,4" TextWrapping="Wrap" />
|
||||||
|
<TextBox Text="{Binding SteamVrProcessesToKill, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas"
|
||||||
|
AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
MinHeight="120" MaxHeight="200"
|
||||||
|
VerticalScrollBarVisibility="Auto" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Args par défaut : appliqués à CHAQUE lancement de PROSERVE, mode
|
||||||
|
auto ou pas. Utile pour des flags globaux (ex : -nosplash, -log,
|
||||||
|
-fps=90) qui doivent s'appliquer quelle que soit la version lancée
|
||||||
|
manuellement. Les args du mode auto (bloc suivant) s'ajoutent en
|
||||||
|
plus, uniquement quand la version courante est celle désignée AUTO. -->
|
||||||
|
<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.SettingsLaunchArgs}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,8" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsLaunchArgsHelp}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Margin="0,0,0,10" />
|
||||||
|
|
||||||
|
<!-- Liste éditable identique à celle du mode auto : Key + Value + Remove. -->
|
||||||
|
<ItemsControl ItemsSource="{Binding DefaultLaunchArgs}">
|
||||||
|
<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 AddDefaultLaunchArgCommand}"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Margin="0,4,0,0" Padding="12,6" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Mode auto : auto-launch + auto-relaunch d'une version désignée -->
|
||||||
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="20" Margin="0,0,0,16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsAutoMode}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<CheckBox IsChecked="{Binding AutoModeFeatureEnabled}"
|
||||||
|
Margin="0,4,0,0"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsAutoModeFeatureEnabled}" />
|
||||||
|
|
||||||
|
<CheckBox IsChecked="{Binding AutoModeWaitForHealthChecks}"
|
||||||
|
Margin="0,8,0,0"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsAutoModeWaitForHealth}" />
|
||||||
|
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="100" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{x:Static loc:Strings.SettingsAutoModeGrace}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Column="1"
|
||||||
|
Text="{Binding AutoModeGraceSeconds, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Margin="0,12,0,4"
|
||||||
|
Text="{Binding AutoModeSelectedVersion, StringFormat='Version désignée actuelle : v{0}', FallbackValue='Aucune version désignée'}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" FontStyle="Italic" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsAutoModeArgs}"
|
||||||
|
FontWeight="Bold" FontSize="11"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,16,0,6" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<!-- Liste éditable d'arguments. Chaque ligne : Key + Value (optionnel) + Remove. -->
|
||||||
|
<ItemsControl ItemsSource="{Binding AutoModeArgs}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid Margin="0,0,0,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox Grid.Column="0"
|
||||||
|
Text="{Binding Key, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas"
|
||||||
|
Tag="{x:Static loc:Strings.SettingsAutoModeArgKey}"
|
||||||
|
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgKey}" />
|
||||||
|
<TextBox Grid.Column="1"
|
||||||
|
Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas"
|
||||||
|
Margin="6,0,6,0"
|
||||||
|
Tag="{x:Static loc:Strings.SettingsAutoModeArgValue}"
|
||||||
|
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgValue}" />
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsAutoModeArgRemove}"
|
||||||
|
Command="{Binding RemoveCommand}"
|
||||||
|
Padding="10,6" />
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsAutoModeArgAdd}"
|
||||||
|
Command="{Binding AddAutoModeArgCommand}"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Margin="0,4,0,0" Padding="12,6" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Cache LAN P2P (peers du LAN partagent les ZIPs entre eux) -->
|
<!-- 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0"
|
<TextBlock Grid.Row="0"
|
||||||
@@ -37,7 +38,16 @@
|
|||||||
Background="Transparent" />
|
Background="Transparent" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
|
<CheckBox Grid.Row="3"
|
||||||
|
x:Name="PreserveSaveGamesCheckBox"
|
||||||
|
Content="{x:Static loc:Strings.UpdatePreserveSaveGames}"
|
||||||
|
ToolTip="{x:Static loc:Strings.UpdatePreserveSaveGamesTooltip}"
|
||||||
|
IsChecked="True"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Margin="0,14,0,0"
|
||||||
|
HorizontalAlignment="Left" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
|
||||||
<Button Style="{StaticResource SecondaryButton}"
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
Content="{x:Static loc:Strings.ActionLater}"
|
Content="{x:Static loc:Strings.ActionLater}"
|
||||||
IsCancel="True"
|
IsCancel="True"
|
||||||
|
|||||||
@@ -24,9 +24,18 @@ public partial class UpdateAvailableDialog : Window
|
|||||||
|
|
||||||
public bool DownloadRequested { get; private set; }
|
public bool DownloadRequested { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// État de la case « Conserver les sauvegardes de la version précédente ».
|
||||||
|
/// Cochée par défaut, lue par MainViewModel APRÈS l'extraction du ZIP pour
|
||||||
|
/// décider s'il faut copier les .sav depuis l'install précédente vers la
|
||||||
|
/// nouvelle (voir CopyPreviousSaveGamesAsync).
|
||||||
|
/// </summary>
|
||||||
|
public bool PreserveSaveGames { get; private set; } = true;
|
||||||
|
|
||||||
private void OnDownload(object sender, RoutedEventArgs e)
|
private void OnDownload(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
DownloadRequested = true;
|
DownloadRequested = true;
|
||||||
|
PreserveSaveGames = PreserveSaveGamesCheckBox.IsChecked == true;
|
||||||
DialogResult = true;
|
DialogResult = true;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,20 @@ public sealed class ApiToolDeployer : IApiToolDeployer
|
|||||||
|
|
||||||
if (!Directory.Exists(cfg.HtdocsRoot))
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
// Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(cfg.HtdocsRoot);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
|
||||||
|
cfg.HtdocsRoot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
|
||||||
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||||
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var stagingTarget = target + ".new";
|
var stagingTarget = target + ".new";
|
||||||
|
|||||||
@@ -45,9 +45,20 @@ public sealed class DocToolDeployer : IDocToolDeployer
|
|||||||
|
|
||||||
if (!Directory.Exists(cfg.HtdocsRoot))
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
// Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(cfg.HtdocsRoot);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
|
||||||
|
cfg.HtdocsRoot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
|
||||||
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||||
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var stagingTarget = target + ".new";
|
var stagingTarget = target + ".new";
|
||||||
|
|||||||
@@ -298,7 +298,12 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
|
|
||||||
// 4. Lance les workers en parallèle.
|
// 4. Lance les workers en parallèle.
|
||||||
// Compteur agrégé via Interlocked → pas de lock dans le hot path.
|
// Compteur agrégé via Interlocked → pas de lock dans le hot path.
|
||||||
long aggregateBytes = state.DownloadedBytes;
|
// Initialisé sur la somme des seg.DownloadedBytes DURABLES plutôt que sur
|
||||||
|
// state.DownloadedBytes (live aggregate persisté, qui peut être en avance
|
||||||
|
// de la réalité disque si pause survenue entre checkpoints). Sans ça, le
|
||||||
|
// footer afficherait "paused at X" mais segments resumeraient à un point
|
||||||
|
// en arrière, et l'aggregate finirait avec un drift cosmétique.
|
||||||
|
long aggregateBytes = state.Segments.Sum(s => s.DownloadedBytes);
|
||||||
|
|
||||||
// Action passée aux segments : juste un Add atomique (zéro contention).
|
// Action passée aux segments : juste un Add atomique (zéro contention).
|
||||||
void OnSegmentBytes(int _, long deltaBytes)
|
void OnSegmentBytes(int _, long deltaBytes)
|
||||||
@@ -307,14 +312,26 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
// Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte.
|
// Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte.
|
||||||
// Tous les 5 s ou 50 Mo, persist le state.json.
|
// Tous les 5 s ou 50 Mo, persist le state.json.
|
||||||
// Tourne sur un thread séparé pour ne jamais être starvé par les workers DL.
|
// Tourne sur un thread séparé pour ne jamais être starvé par les workers DL.
|
||||||
|
//
|
||||||
|
// Calcul du débit : on utilise une FENÊTRE GLISSANTE de ~3 s (12 samples
|
||||||
|
// de 250ms) plutôt qu'un delta instantané sur le dernier tick. Pourquoi :
|
||||||
|
// avec un delta tick-à-tick, dès qu'un segment finit OU que Polly retry
|
||||||
|
// entre 2 tentatives OU qu'il y a un micro-stall réseau, le compteur ne
|
||||||
|
// bouge pas pendant 1-2 ticks → bps = 0 → ETA = null → UI cache les
|
||||||
|
// deux 250-500 ms → clignotement permanent à l'écran (symptôme rapporté).
|
||||||
|
// Avec la fenêtre, un trou de 1-2 ticks est dilué dans 12 samples → le
|
||||||
|
// débit affiché reste stable. La latence d'adaptation est ~3s, ce qui
|
||||||
|
// est largement OK visuellement (l'opérateur ne perçoit pas 3s de retard
|
||||||
|
// sur un chiffre qui de toute façon fluctue de ±5-10 % au cours d'un DL).
|
||||||
|
const int BpsWindowSize = 12; // 12 × 250 ms = 3 s
|
||||||
using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
var reporterTask = Task.Run(async () =>
|
var reporterTask = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
var lastReport = TimeSpan.Zero;
|
|
||||||
long lastReportBytes = Interlocked.Read(ref aggregateBytes);
|
|
||||||
var lastFlush = TimeSpan.Zero;
|
var lastFlush = TimeSpan.Zero;
|
||||||
long lastFlushBytes = lastReportBytes;
|
long lastFlushBytes = Interlocked.Read(ref aggregateBytes);
|
||||||
|
// Fenêtre glissante (time, bytes) pour lissage du débit.
|
||||||
|
var bpsWindow = new Queue<(TimeSpan At, long Bytes)>(BpsWindowSize + 1);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!reporterCts.IsCancellationRequested)
|
while (!reporterCts.IsCancellationRequested)
|
||||||
@@ -322,13 +339,24 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
|
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
|
||||||
var snapshot = Interlocked.Read(ref aggregateBytes);
|
var snapshot = Interlocked.Read(ref aggregateBytes);
|
||||||
var elapsed = sw.Elapsed;
|
var elapsed = sw.Elapsed;
|
||||||
var deltaSec = (elapsed - lastReport).TotalSeconds;
|
|
||||||
var bps = deltaSec > 0 ? (snapshot - lastReportBytes) / deltaSec : 0;
|
// Push le nouveau sample, évince le plus ancien si on dépasse.
|
||||||
|
bpsWindow.Enqueue((elapsed, snapshot));
|
||||||
|
while (bpsWindow.Count > BpsWindowSize) bpsWindow.Dequeue();
|
||||||
|
|
||||||
|
// bps = (bytes_now - bytes_oldest_in_window) / time_span_window
|
||||||
|
// Fallback à 0 tant qu'on n'a pas au moins 2 samples (le 1er tick).
|
||||||
|
double bps = 0;
|
||||||
|
if (bpsWindow.Count >= 2)
|
||||||
|
{
|
||||||
|
var oldest = bpsWindow.Peek();
|
||||||
|
var span = (elapsed - oldest.At).TotalSeconds;
|
||||||
|
if (span > 0) bps = (snapshot - oldest.Bytes) / span;
|
||||||
|
}
|
||||||
TimeSpan? eta = null;
|
TimeSpan? eta = null;
|
||||||
if (bps > 0 && total > snapshot) eta = TimeSpan.FromSeconds((total - snapshot) / bps);
|
if (bps > 0 && total > snapshot)
|
||||||
|
eta = TimeSpan.FromSeconds((total - snapshot) / bps);
|
||||||
progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
|
progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
|
||||||
lastReport = elapsed;
|
|
||||||
lastReportBytes = snapshot;
|
|
||||||
|
|
||||||
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
|
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
|
||||||
snapshot - lastFlushBytes >= StateFlushBytesInterval)
|
snapshot - lastFlushBytes >= StateFlushBytesInterval)
|
||||||
@@ -450,16 +478,84 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
|
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 403 / 410 = URL HMAC expirée (gate.php renvoie 403 "Forbidden: expired").
|
// 403 / 410 / 404 = URL HMAC expirée (gate.php renvoie 403 "Forbidden:
|
||||||
// On force un refresh d'URL via le callback du job, puis on jette une
|
// expired"), OU le fichier a été renommé/supprimé côté serveur (cas
|
||||||
// exception transitoire pour que Polly retry avec la nouvelle URL.
|
// typique : ré-upload du ZIP avec un nouveau nom pour invalider le cache
|
||||||
if (resp.StatusCode == HttpStatusCode.Forbidden || resp.StatusCode == HttpStatusCode.Gone)
|
// OVH CDN). Dans les deux cas, refresh l'URL via le callback du job —
|
||||||
{
|
// l'endpoint /download-url/{version} renvoie la NOUVELLE URL pointant
|
||||||
if (job.RefreshUrlAsync is not null)
|
// vers le fichier actuellement présent sur le serveur. Si refresh donne
|
||||||
|
// une URL DIFFÉRENTE de celle qu'on a utilisée dans la requête → retry
|
||||||
|
// avec la nouvelle. Sinon → c'est un vrai 404, on bubble.
|
||||||
|
//
|
||||||
|
// Note importante : on compare à `url` (la valeur utilisée dans la
|
||||||
|
// requête HTTP, capturée ligne 438 avant le send) plutôt qu'à un
|
||||||
|
// snapshot pris juste avant forceRefresh. Pourquoi : si 8 segments
|
||||||
|
// tombent en 404 simultanément, le PREMIER refresh met _currentUrl à
|
||||||
|
// jour, les autres voient le debounce 5s (no-op). Comparer à `url`
|
||||||
|
// (= URL réellement utilisée par CETTE requête, OLD) détecte
|
||||||
|
// correctement que _currentUrl est maintenant NEW → retry. Si on
|
||||||
|
// comparait à un snapshot pré-refresh, on raterait ce cas et on jetterait
|
||||||
|
// un faux 404 fatal sur tous les segments sauf le premier.
|
||||||
|
bool isUrlRefreshTrigger = resp.StatusCode == HttpStatusCode.Forbidden
|
||||||
|
|| resp.StatusCode == HttpStatusCode.Gone
|
||||||
|
|| resp.StatusCode == HttpStatusCode.NotFound
|
||||||
|
|| resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable;
|
||||||
|
if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null)
|
||||||
{
|
{
|
||||||
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
|
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
|
||||||
throw new HttpResumableException("Signed URL expired, refreshed for next try", isTransient: true);
|
bool urlChanged = !Uri.Equals(url, _currentUrl);
|
||||||
|
if (urlChanged)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Got HTTP {Status} on segment {Seg}, URL refreshed (old={Old} → new={New}), retrying",
|
||||||
|
(int)resp.StatusCode, seg.Index, url, _currentUrl);
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"HTTP {(int)resp.StatusCode} on segment {seg.Index}, URL refreshed",
|
||||||
|
isTransient: true);
|
||||||
}
|
}
|
||||||
|
// URL unchanged → soit le serveur insiste sur la même URL morte,
|
||||||
|
// soit le refresh a fallback sur le manifest cached (donc stale).
|
||||||
|
// 404 non-transient pour arrêter Polly et remonter une erreur claire
|
||||||
|
// qui guide l'opérateur vers « Vérifier les MAJ ».
|
||||||
|
if (resp.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"HTTP 404 on segment {seg.Index} — le fichier a été renommé/supprimé côté serveur et le manifest local est obsolète. Clique « Vérifier les MAJ » avant de réessayer.",
|
||||||
|
isTransient: false);
|
||||||
|
}
|
||||||
|
// 416 unchanged URL = vrai mismatch de taille. On fait un HEAD probe
|
||||||
|
// pour obtenir la taille RÉELLE côté serveur et la rapporter dans le
|
||||||
|
// message d'erreur — l'opérateur saura ainsi s'il a un upload SFTP
|
||||||
|
// tronqué (file plus petit que prévu) ou un manifest qui ment sur
|
||||||
|
// sizeBytes. Cas concret : SFTP coupe au milieu d'un upload de 13 Go
|
||||||
|
// → le file fait 7 Go sur disque, mais le manifest dit 13 Go.
|
||||||
|
// Segment N qui demande bytes 9G-10G reçoit 416 (out of range).
|
||||||
|
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
|
||||||
|
{
|
||||||
|
long? actualSize = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var headReq = new HttpRequestMessage(HttpMethod.Head, url);
|
||||||
|
using var headResp = await _http.SendAsync(headReq, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||||
|
if (headResp.IsSuccessStatusCode)
|
||||||
|
actualSize = headResp.Content.Headers.ContentLength;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _logger.LogDebug(ex, "HEAD probe after 416 failed"); }
|
||||||
|
|
||||||
|
var actualStr = actualSize.HasValue ? $"{actualSize.Value:N0}" : "?";
|
||||||
|
var expectedStr = $"{state.TotalBytes:N0}";
|
||||||
|
_logger.LogError(
|
||||||
|
"HTTP 416 on segment {Seg} — actual server file size: {Actual} bytes, manifest expects: {Expected} bytes",
|
||||||
|
seg.Index, actualStr, expectedStr);
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"HTTP 416 on segment {seg.Index} — incohérence taille de fichier serveur : " +
|
||||||
|
$"le ZIP sur le serveur fait {actualStr} octets, le manifest attend {expectedStr} octets. " +
|
||||||
|
$"Probable cause : upload SFTP tronqué OU manifest avec sizeBytes/sha256 obsolètes. " +
|
||||||
|
$"Re-upload le ZIP complet puis « 🔁 Hasher les versions + signer » côté admin.",
|
||||||
|
isTransient: false);
|
||||||
|
}
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"Signed URL expired (HTTP {(int)resp.StatusCode}), refreshed for next try", isTransient: true);
|
||||||
}
|
}
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -474,15 +570,51 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true);
|
await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true);
|
||||||
dst.Seek(segStart, SeekOrigin.Begin);
|
dst.Seek(segStart, SeekOrigin.Begin);
|
||||||
|
|
||||||
|
// CHECKPOINT : durabilité ET anti-double-comptage.
|
||||||
|
//
|
||||||
|
// Durabilité : on n'incrémente seg.DownloadedBytes qu'APRÈS un FlushAsync
|
||||||
|
// réussi. Sans ça, sur pause/cancel, le buffer FileStream (4 MiB) ou le
|
||||||
|
// cache OS peuvent ne pas avoir atteint le disque, mais seg.DownloadedBytes
|
||||||
|
// les compte comme écrits → resume saute ces bytes → trou (zéros NTFS du
|
||||||
|
// pre-alloc sparse) → SHA-256 fail.
|
||||||
|
//
|
||||||
|
// Anti-double-comptage : on appelle ÉGALEMENT `onBytes` uniquement au
|
||||||
|
// checkpoint, avec le delta inFlight. Sans ça, un retry Polly mid-segment
|
||||||
|
// (très fréquent sur PHP-FPM OVH mutualisé qui coupe les requêtes longues)
|
||||||
|
// re-télécharge les bytes depuis le dernier checkpoint et les compte une
|
||||||
|
// 2e fois dans l'aggregate → footer affiche >100% en fin de DL.
|
||||||
|
// Avec onBytes au checkpoint : les bytes re-téléchargés n'ont jamais été
|
||||||
|
// reportés à la 1re tentative (l'erreur Polly est levée AVANT le checkpoint
|
||||||
|
// sur l'attempt failed), donc pas de double count.
|
||||||
|
//
|
||||||
|
// Trade-off : UI/footer update tous les 64 MiB par segment au lieu de
|
||||||
|
// chaque 4 MiB. Avec 16 segments en parallèle, ça reste ~10-20 updates/s
|
||||||
|
// au pic, le reporter task échantillonne à 4 Hz donc invisible côté UX.
|
||||||
|
const long FlushIntervalBytes = 64L * 1024 * 1024;
|
||||||
var buffer = new byte[BufferSize];
|
var buffer = new byte[BufferSize];
|
||||||
|
long inFlight = 0;
|
||||||
int n;
|
int n;
|
||||||
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
|
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
|
||||||
{
|
{
|
||||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
||||||
seg.DownloadedBytes += n;
|
inFlight += n;
|
||||||
onBytes(seg.Index, n);
|
|
||||||
|
if (inFlight >= FlushIntervalBytes)
|
||||||
|
{
|
||||||
|
await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
seg.DownloadedBytes += inFlight;
|
||||||
|
onBytes(seg.Index, inFlight);
|
||||||
|
inFlight = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Checkpoint final : flush + ack du reste du buffer.
|
||||||
|
await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (inFlight > 0)
|
||||||
|
{
|
||||||
|
seg.DownloadedBytes += inFlight;
|
||||||
|
onBytes(seg.Index, inFlight);
|
||||||
|
inFlight = 0;
|
||||||
}
|
}
|
||||||
await dst.FlushAsync(ct).ConfigureAwait(false);
|
|
||||||
seg.Completed = (seg.DownloadedBytes >= seg.Length);
|
seg.Completed = (seg.DownloadedBytes >= seg.Length);
|
||||||
|
|
||||||
// Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a
|
// Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a
|
||||||
|
|||||||
@@ -8,4 +8,29 @@ public interface IInstallationRegistry
|
|||||||
InstalledVersion? Get(string version);
|
InstalledVersion? Get(string version);
|
||||||
bool IsInstalled(string version);
|
bool IsInstalled(string version);
|
||||||
Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct);
|
Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Écrit le metadata <c>.proserve-meta.json</c> dans le dossier d'install avec
|
||||||
|
/// le nom du .exe à lancer (récupéré du manifest serveur) + l'identifiant
|
||||||
|
/// d'entrée manifest (<paramref name="entryId"/>) pour détecter les
|
||||||
|
/// collisions inter-channels. À appeler par <c>MainViewModel</c> après
|
||||||
|
/// l'extraction du ZIP, pour que le futur <c>Scan</c> trouve le bon exe et
|
||||||
|
/// pour que l'install guard puisse comparer l'entryId d'un dossier existant
|
||||||
|
/// avant écrasement.
|
||||||
|
/// </summary>
|
||||||
|
Task WriteInstallMetadataAsync(string installDir, string executableName, string? entryId, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Marque les redists comme installés (timestamp UTC) dans la metadata.</summary>
|
||||||
|
Task MarkRedistInstalledAsync(string installDir, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Vrai si la metadata indique que les redists ont déjà été installés.</summary>
|
||||||
|
bool IsRedistInstalled(string installDir);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lit l'<c>entryId</c> stocké dans <c>.proserve-meta.json</c> du dossier
|
||||||
|
/// d'install. Retourne null si le fichier n'existe pas, si la clé n'a
|
||||||
|
/// jamais été écrite (installs antérieurs à ce feature), ou en cas
|
||||||
|
/// d'erreur de parse. Utilisé pour l'install guard anti-collision.
|
||||||
|
/// </summary>
|
||||||
|
string? TryReadEntryId(string installDir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
@@ -6,11 +7,35 @@ namespace PSLauncher.Core.Installations;
|
|||||||
|
|
||||||
public sealed partial class InstallationRegistry : IInstallationRegistry
|
public sealed partial class InstallationRegistry : IInstallationRegistry
|
||||||
{
|
{
|
||||||
private const string ExecutableName = "PROSERVE_UE_5_5.exe";
|
/// <summary>
|
||||||
|
/// Fichier metadata écrit à l'install par MainViewModel. Contient le nom de l'exe
|
||||||
|
/// déclaré dans le manifest pour cette version. Permet à <see cref="Scan"/> de
|
||||||
|
/// retrouver le bon .exe à lancer sans avoir le manifest en mémoire (cas du
|
||||||
|
/// démarrage offline ou avant le 1er Check Updates).
|
||||||
|
/// </summary>
|
||||||
|
public const string MetadataFileName = ".proserve-meta.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pattern de fallback quand .proserve-meta.json est absent (vieux installs
|
||||||
|
/// d'avant l'introduction du metadata). Couvre toutes les versions Unreal
|
||||||
|
/// connues (UE_5_5, UE_5_7, etc.). Premier match alphabétique gagne.
|
||||||
|
/// </summary>
|
||||||
|
private const string FallbackExePattern = "PROSERVE_UE_*.exe";
|
||||||
|
|
||||||
// IgnoreCase pour matcher à la fois les anciens dossiers "Proserve vX.Y.Z" et les
|
// IgnoreCase pour matcher à la fois les anciens dossiers "Proserve vX.Y.Z" et les
|
||||||
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque).
|
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque). Le 4ᵉ digit
|
||||||
[GeneratedRegex(@"^PROSERVE v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
|
// optionnel (?:\.\d+)? permet aux itérations dev/test (1.5.4.13) de
|
||||||
|
// cohabiter avec leur release stable (1.5.4) dans deux dossiers distincts.
|
||||||
|
//
|
||||||
|
// Le groupe optionnel `(-<channel>)?` permet de discriminer plusieurs installs
|
||||||
|
// au même numéro de version mais sur des channels différents :
|
||||||
|
// PROSERVE v1.5.4.32
|
||||||
|
// PROSERVE-firefighter v1.5.4.32
|
||||||
|
// PROSERVE-full v1.5.4.32
|
||||||
|
// Le channel n'est PAS capturé ici — c'est juste un suffixe autorisé dans le
|
||||||
|
// nom de dossier. L'association exacte channel ↔ dossier passe par le lookup
|
||||||
|
// <see cref="VersionManifest.GetInstallFolderName"/> dans MainViewModel.
|
||||||
|
[GeneratedRegex(@"^PROSERVE(-[a-z0-9_-]+)? v(?<v>\d+\.\d+\.\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex VersionFolderRegex();
|
private static partial Regex VersionFolderRegex();
|
||||||
|
|
||||||
private readonly ILogger<InstallationRegistry> _logger;
|
private readonly ILogger<InstallationRegistry> _logger;
|
||||||
@@ -38,10 +63,10 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
|
|||||||
var match = VersionFolderRegex().Match(name);
|
var match = VersionFolderRegex().Match(name);
|
||||||
if (!match.Success) continue;
|
if (!match.Success) continue;
|
||||||
|
|
||||||
var exe = Path.Combine(dir, ExecutableName);
|
var exe = ResolveExecutable(dir);
|
||||||
if (!File.Exists(exe))
|
if (exe is null)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Skipped {Dir}: missing {Exe}", dir, ExecutableName);
|
_logger.LogDebug("Skipped {Dir}: no executable found (no .proserve-meta.json + no fallback match)", dir);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +76,8 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
|
|||||||
FolderPath: dir,
|
FolderPath: dir,
|
||||||
ExecutablePath: exe,
|
ExecutablePath: exe,
|
||||||
InstalledAt: info.CreationTimeUtc,
|
InstalledAt: info.CreationTimeUtc,
|
||||||
SizeBytes: SafeDirectorySize(dir)));
|
SizeBytes: SafeDirectorySize(dir),
|
||||||
|
EntryId: TryReadEntryId(dir)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
@@ -59,6 +85,145 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Résout le .exe à lancer pour un dossier d'install donné. Stratégie :
|
||||||
|
/// 1. Lit <c>.proserve-meta.json</c> (écrit par MainViewModel à l'install,
|
||||||
|
/// contient l'exe déclaré dans le manifest serveur — source canonique).
|
||||||
|
/// 2. Fallback glob <c>PROSERVE_UE_*.exe</c> pour les vieux installs sans meta.
|
||||||
|
/// 3. Null si aucun match (dossier corrompu ou pas un install PROSERVE).
|
||||||
|
/// </summary>
|
||||||
|
private string? ResolveExecutable(string installDir)
|
||||||
|
{
|
||||||
|
// Tentative 1 : metadata écrite par le launcher au moment de l'install
|
||||||
|
var metaPath = Path.Combine(installDir, MetadataFileName);
|
||||||
|
if (File.Exists(metaPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var raw = File.ReadAllText(metaPath);
|
||||||
|
var meta = JsonSerializer.Deserialize<InstallMetadata>(raw);
|
||||||
|
if (meta is { Executable: var exeName } && !string.IsNullOrWhiteSpace(exeName))
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(installDir, exeName);
|
||||||
|
if (File.Exists(candidate)) return candidate;
|
||||||
|
_logger.LogWarning("Metadata declares {Exe} but file missing in {Dir} — falling back to glob", exeName, installDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to parse {Meta} (will fall back to glob)", metaPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentative 2 : glob pattern (couvre vieux installs sans metadata)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var matches = Directory.GetFiles(installDir, FallbackExePattern, SearchOption.TopDirectoryOnly);
|
||||||
|
if (matches.Length > 0)
|
||||||
|
{
|
||||||
|
Array.Sort(matches, StringComparer.OrdinalIgnoreCase);
|
||||||
|
return matches[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* ignore IO error */ }
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Écrit le fichier metadata <c>.proserve-meta.json</c> dans le dossier d'install
|
||||||
|
/// fraîchement extrait. Appelé par <see cref="MainViewModel"/> après le ZipInstaller,
|
||||||
|
/// avec l'exe déclaré dans le manifest (<c>VersionManifest.Executable</c>) et
|
||||||
|
/// l'<paramref name="entryId"/> de l'entrée manifest source. L'entryId sert au
|
||||||
|
/// pre-install guard : un futur install qui vise le même dossier avec un id
|
||||||
|
/// différent (= une autre entrée manifest sur un channel différent partageant
|
||||||
|
/// le numéro de version) sera refusé pour éviter l'écrasement silencieux.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteInstallMetadataAsync(string installDir, string executableName, string? entryId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(installDir, MetadataFileName);
|
||||||
|
// Préserve un éventuel RedistInstalledAt existant (relance install sans
|
||||||
|
// ré-installer les redists → on ne veut pas perdre le timestamp).
|
||||||
|
InstallMetadata? existing = null;
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
try { existing = JsonSerializer.Deserialize<InstallMetadata>(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); }
|
||||||
|
catch { existing = null; }
|
||||||
|
}
|
||||||
|
var meta = new InstallMetadata
|
||||||
|
{
|
||||||
|
Executable = executableName,
|
||||||
|
EntryId = entryId,
|
||||||
|
RedistInstalledAt = existing?.RedistInstalledAt,
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string? TryReadEntryId(string installDir)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(installDir, MetadataFileName);
|
||||||
|
if (!File.Exists(path)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = JsonSerializer.Deserialize<InstallMetadata>(File.ReadAllText(path));
|
||||||
|
return string.IsNullOrWhiteSpace(meta?.EntryId) ? null : meta.EntryId;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marque dans la metadata qu'on a installé avec succès les redists Unreal
|
||||||
|
/// (contenu de <c>_redist/</c> de la version). Évite de re-runner les UAC
|
||||||
|
/// popups si l'utilisateur réinstalle la même version.
|
||||||
|
/// </summary>
|
||||||
|
public async Task MarkRedistInstalledAsync(string installDir, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(installDir, MetadataFileName);
|
||||||
|
InstallMetadata? meta = null;
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
try { meta = JsonSerializer.Deserialize<InstallMetadata>(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); }
|
||||||
|
catch { meta = null; }
|
||||||
|
}
|
||||||
|
meta ??= new InstallMetadata();
|
||||||
|
meta.RedistInstalledAt = DateTime.UtcNow;
|
||||||
|
var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vrai si l'install dir a déjà eu son <c>_redist/</c> installé une fois (via
|
||||||
|
/// metadata). Permet de skip le UAC popup chain à chaque réinstall de la même
|
||||||
|
/// version.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsRedistInstalled(string installDir)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(installDir, MetadataFileName);
|
||||||
|
if (!File.Exists(path)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = JsonSerializer.Deserialize<InstallMetadata>(File.ReadAllText(path));
|
||||||
|
return meta?.RedistInstalledAt is not null;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InstallMetadata
|
||||||
|
{
|
||||||
|
public string Executable { get; set; } = string.Empty;
|
||||||
|
public DateTime? RedistInstalledAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Id de l'entrée manifest source (<see cref="VersionManifest.Id"/>). Null
|
||||||
|
/// pour les installs antérieurs à cette feature (rétro-compat). Utilisé
|
||||||
|
/// par l'install guard : refuse un install si un autre entryId est déjà
|
||||||
|
/// présent dans le dossier cible.
|
||||||
|
/// </summary>
|
||||||
|
public string? EntryId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public InstalledVersion? Get(string version) =>
|
public InstalledVersion? Get(string version) =>
|
||||||
Scan().FirstOrDefault(v => v.Version == version);
|
Scan().FirstOrDefault(v => v.Version == version);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,52 @@
|
|||||||
namespace PSLauncher.Core.Installations;
|
namespace PSLauncher.Core.Installations;
|
||||||
|
|
||||||
public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable<SemVer>
|
/// <summary>
|
||||||
|
/// Version produit PROSERVE. Formats acceptés :
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>X.Y.Z</c> (3 digits — releases publiques)</item>
|
||||||
|
/// <item><c>X.Y.Z.B</c> (4 digits — itérations dev/test internes)</item>
|
||||||
|
/// </list>
|
||||||
|
/// Le 4ᵉ digit <see cref="Build"/> est sémantiquement "post-release" :
|
||||||
|
/// <c>1.5.4 == 1.5.4.0 < 1.5.4.1 < 1.5.4.13 < 1.5.5</c>.
|
||||||
|
/// L'absence de 4ᵉ digit est équivalente à <c>.0</c> pour la comparaison,
|
||||||
|
/// mais conservée distincte dans <see cref="ToString"/> (= <c>1.5.4</c>,
|
||||||
|
/// PAS <c>1.5.4.0</c>) pour ne pas re-écrire les versions historiques
|
||||||
|
/// du manifest qui n'avaient que 3 digits.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct SemVer(int Major, int Minor, int Patch, int Build = 0) : IComparable<SemVer>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// True si la version originale avait un 4ᵉ digit explicite (même <c>.0</c>).
|
||||||
|
/// Sert uniquement à <see cref="ToString"/> pour préserver le format d'origine
|
||||||
|
/// lors d'un round-trip Parse → ToString.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasExplicitBuild { get; init; }
|
||||||
|
|
||||||
public static SemVer Parse(string s)
|
public static SemVer Parse(string s)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return new SemVer(0, 0, 0);
|
||||||
|
|
||||||
var parts = s.Split('.');
|
var parts = s.Split('.');
|
||||||
if (parts.Length != 3
|
// 3 digits = release publique (X.Y.Z), 4 digits = itération dev (X.Y.Z.B).
|
||||||
|| !int.TryParse(parts[0], out var maj)
|
// Tout autre format = parse-fail → on retourne 0.0.0 (silencieux, comme
|
||||||
|
// l'ancien comportement, pour ne pas casser sur un manifest exotique).
|
||||||
|
if (parts.Length < 3 || parts.Length > 4) return new SemVer(0, 0, 0);
|
||||||
|
if (!int.TryParse(parts[0], out var maj)
|
||||||
|| !int.TryParse(parts[1], out var min)
|
|| !int.TryParse(parts[1], out var min)
|
||||||
|| !int.TryParse(parts[2], out var pat))
|
|| !int.TryParse(parts[2], out var pat))
|
||||||
{
|
{
|
||||||
return new SemVer(0, 0, 0);
|
return new SemVer(0, 0, 0);
|
||||||
}
|
}
|
||||||
return new SemVer(maj, min, pat);
|
if (parts.Length == 3)
|
||||||
|
{
|
||||||
|
return new SemVer(maj, min, pat, 0) { HasExplicitBuild = false };
|
||||||
|
}
|
||||||
|
// 4 digits — itération dev
|
||||||
|
if (!int.TryParse(parts[3], out var build))
|
||||||
|
{
|
||||||
|
return new SemVer(0, 0, 0);
|
||||||
|
}
|
||||||
|
return new SemVer(maj, min, pat, build) { HasExplicitBuild = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
public int CompareTo(SemVer other)
|
public int CompareTo(SemVer other)
|
||||||
@@ -21,8 +55,13 @@ public readonly record struct SemVer(int Major, int Minor, int Patch) : ICompara
|
|||||||
if (c != 0) return c;
|
if (c != 0) return c;
|
||||||
c = Minor.CompareTo(other.Minor);
|
c = Minor.CompareTo(other.Minor);
|
||||||
if (c != 0) return c;
|
if (c != 0) return c;
|
||||||
return Patch.CompareTo(other.Patch);
|
c = Patch.CompareTo(other.Patch);
|
||||||
|
if (c != 0) return c;
|
||||||
|
// Build absent = 0 implicite → 1.5.4 == 1.5.4.0 < 1.5.4.1
|
||||||
|
return Build.CompareTo(other.Build);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => $"{Major}.{Minor}.{Patch}";
|
public override string ToString() => HasExplicitBuild || Build > 0
|
||||||
|
? $"{Major}.{Minor}.{Patch}.{Build}"
|
||||||
|
: $"{Major}.{Minor}.{Patch}";
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/PSLauncher.Core/Installations/VersionOrder.cs
Normal file
55
src/PSLauncher.Core/Installations/VersionOrder.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Installations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comparaison de versions PROSERVE qui prend en compte le flag <c>isBeta</c>
|
||||||
|
/// du manifest en plus du SemVer numérique.
|
||||||
|
///
|
||||||
|
/// Règle : au même préfixe 3-digit <c>X.Y.Z</c>, une version <c>isBeta=false</c>
|
||||||
|
/// est considérée SUPÉRIEURE à une version <c>isBeta=true</c>, quel que soit le
|
||||||
|
/// 4ᵉ digit. Cas d'usage typique : après avoir itéré des builds beta <c>1.5.4.30
|
||||||
|
/// .31 .32</c>, l'opérateur publie <c>1.5.4</c> non-beta comme release finale.
|
||||||
|
/// Sous SemVer pur, <c>1.5.4 == 1.5.4.0 < 1.5.4.32</c> donc le launcher
|
||||||
|
/// resterait pointé sur la beta ; ce helper inverse la comparaison pour ce
|
||||||
|
/// cas précis afin que la release finale soit considérée comme LA version
|
||||||
|
/// courante.
|
||||||
|
///
|
||||||
|
/// Hors ce cas (préfixes 3-digit différents, ou même statut beta des deux
|
||||||
|
/// côtés), on retombe sur <see cref="SemVer.CompareTo"/> — donc :
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>1.5.4 (non-beta)</c> vs <c>1.5.4.32 (beta)</c> → 1.5.4 gagne (règle spéciale)</item>
|
||||||
|
/// <item><c>1.5.4.30 (beta)</c> vs <c>1.5.4.32 (beta)</c> → 1.5.4.32 gagne (SemVer)</item>
|
||||||
|
/// <item><c>1.5.4 (non-beta)</c> vs <c>1.5.5.10 (beta)</c> → 1.5.5.10 gagne (SemVer, préfixes différents)</item>
|
||||||
|
/// <item><c>1.5.4</c> vs <c>1.5.5</c> → 1.5.5 gagne (SemVer)</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static class VersionOrder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retourne <0 si A < B, 0 si égales, >0 si A > B. Voir la doc de
|
||||||
|
/// <see cref="VersionOrder"/> pour la règle isBeta.
|
||||||
|
/// </summary>
|
||||||
|
public static int Compare(string versionA, bool isBetaA, string versionB, bool isBetaB)
|
||||||
|
{
|
||||||
|
var svA = SemVer.Parse(versionA);
|
||||||
|
var svB = SemVer.Parse(versionB);
|
||||||
|
|
||||||
|
// Cas spécial : même préfixe 3-digit ET statut beta différent.
|
||||||
|
// La version non-beta l'emporte, indépendamment du 4ᵉ digit (traité
|
||||||
|
// comme un suffixe pré-release dans ce contexte particulier).
|
||||||
|
if (svA.Major == svB.Major
|
||||||
|
&& svA.Minor == svB.Minor
|
||||||
|
&& svA.Patch == svB.Patch
|
||||||
|
&& isBetaA != isBetaB)
|
||||||
|
{
|
||||||
|
return isBetaA ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return svA.CompareTo(svB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Overload pratique pour une <see cref="VersionManifest"/>.</summary>
|
||||||
|
public static int Compare(VersionManifest a, VersionManifest b)
|
||||||
|
=> Compare(a.Version, a.IsBeta, b.Version, b.IsBeta);
|
||||||
|
}
|
||||||
@@ -26,11 +26,70 @@ public sealed class ZipInstaller : IZipInstaller
|
|||||||
{
|
{
|
||||||
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false);
|
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Si la version est DÉJÀ installée (cas typique : ré-installation
|
||||||
|
// d'un build sans bumper le numéro de version pendant des tests),
|
||||||
|
// on renomme l'existant en backup horodaté pour ne pas bloquer
|
||||||
|
// l'install. Le backup est supprimé en arrière-plan après que le
|
||||||
|
// swap soit confirmé (sinon on doublerait la conso disque le temps
|
||||||
|
// de la copie). Si le swap échoue, on restaure le backup.
|
||||||
|
string? renamedBackup = null;
|
||||||
if (Directory.Exists(targetFolder))
|
if (Directory.Exists(targetFolder))
|
||||||
throw new InvalidOperationException($"Le dossier cible existe déjà : {targetFolder}");
|
{
|
||||||
|
renamedBackup = $"{targetFolder}.bak-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
|
||||||
|
Directory.Move(targetFolder, renamedBackup);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Existing install at {Target} renamed to {Backup} before re-install",
|
||||||
|
targetFolder, renamedBackup);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
Directory.Move(stagingFolder, targetFolder);
|
Directory.Move(stagingFolder, targetFolder);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Rollback : restaure le backup si on a échoué après le rename
|
||||||
|
if (renamedBackup is not null && Directory.Exists(renamedBackup) && !Directory.Exists(targetFolder))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Move(renamedBackup, targetFolder);
|
||||||
|
_logger.LogWarning("Restored previous install from {Backup} after move failure", renamedBackup);
|
||||||
|
}
|
||||||
|
catch (Exception restoreEx)
|
||||||
|
{
|
||||||
|
_logger.LogError(restoreEx,
|
||||||
|
"FAILED to restore backup {Backup} — manual recovery needed", renamedBackup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Installed to {Target}", targetFolder);
|
_logger.LogInformation("Installed to {Target}", targetFolder);
|
||||||
|
|
||||||
|
// Cleanup background du backup (14 Go d'install × N réinstallations
|
||||||
|
// remplirait le disque sinon). Best-effort : si on ne peut pas
|
||||||
|
// delete (handle Windows tenace, AV qui scanne), on log et on
|
||||||
|
// laisse à l'opérateur (le nom .bak-{ts} le rend repérable).
|
||||||
|
if (renamedBackup is not null)
|
||||||
|
{
|
||||||
|
var bakToDelete = renamedBackup;
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(bakToDelete, recursive: true);
|
||||||
|
_logger.LogInformation("Cleaned up old install backup {Backup}", bakToDelete);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Could not delete old install backup {Backup} — operator can remove manually",
|
||||||
|
bakToDelete);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return targetFolder;
|
return targetFolder;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -45,4 +45,20 @@ public interface IZipCacheStore
|
|||||||
|
|
||||||
/// <summary>Liste tous les ZIPs cachés valides (avec sidecar SHA présent).</summary>
|
/// <summary>Liste tous les ZIPs cachés valides (avec sidecar SHA présent).</summary>
|
||||||
IReadOnlyList<CachedZip> ListCached();
|
IReadOnlyList<CachedZip> ListCached();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supprime AGRESSIVEMENT le ZIP + sidecar SHA d'une version donnée du cache
|
||||||
|
/// local, peu importe son âge ou si la version est encore installée. Utilisé
|
||||||
|
/// dans deux cas :
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Échec de vérif SHA après DL : on évacue l'éventuel ZIP cache de
|
||||||
|
/// la version concernée pour ne pas servir du contenu obsolète aux peers
|
||||||
|
/// LAN (et pour forcer un re-DL complet au prochain Install).</item>
|
||||||
|
/// <item>Action manuelle "Forcer re-téléchargement" depuis le menu UI :
|
||||||
|
/// l'opérateur veut purger explicitement (typiquement quand la source
|
||||||
|
/// OVH est en cache CDN stale et qu'il a besoin d'un re-DL frais).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Best-effort : log les erreurs I/O mais ne throw pas.
|
||||||
|
/// </summary>
|
||||||
|
Task InvalidateAsync(string version, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -137,6 +149,47 @@ public sealed class ZipCacheStore : IZipCacheStore
|
|||||||
}, ct).ConfigureAwait(false);
|
}, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task InvalidateAsync(string version, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!VersionRegex.IsMatch(version))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("InvalidateAsync called with invalid version: {Version}", version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var zipPath = GetCachedZipPath(version);
|
||||||
|
var shaPath = GetShaPath(version);
|
||||||
|
bool zipDeleted = false, shaDeleted = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(zipPath)) { File.Delete(zipPath); zipDeleted = true; }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not delete cached ZIP for v{Version} at {Path} (file lock?)",
|
||||||
|
version, zipPath);
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(shaPath)) { File.Delete(shaPath); shaDeleted = true; }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not delete sidecar SHA for v{Version} at {Path}",
|
||||||
|
version, shaPath);
|
||||||
|
}
|
||||||
|
if (zipDeleted || shaDeleted)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Invalidated LAN cache for v{Version} (zip={ZipGone}, sidecar={ShaGone})",
|
||||||
|
version, zipDeleted, shaDeleted);
|
||||||
|
}
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<CachedZip> ListCached()
|
public IReadOnlyList<CachedZip> ListCached()
|
||||||
{
|
{
|
||||||
var dir = _downloadStore.GetDownloadsDirectory();
|
var dir = _downloadStore.GetDownloadsDirectory();
|
||||||
|
|||||||
@@ -16,8 +16,14 @@ public interface ILicenseService
|
|||||||
/// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version.
|
/// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version.
|
||||||
/// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
|
/// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
|
||||||
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
|
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
|
||||||
|
/// <paramref name="expectedFilename"/> est le nom du fichier attendu, extrait du
|
||||||
|
/// <c>download.url</c> côté manifest client. Sert au serveur à disambiguer les
|
||||||
|
/// entrées quand plusieurs channels partagent le même numéro de version
|
||||||
|
/// (proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32 sur v1.5.4.32).
|
||||||
|
/// Si null ou vide, le serveur retombe sur "1re entrée matchant le numéro"
|
||||||
|
/// (rétro-compat avec les vieux serveurs qui ignorent ce paramètre).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct);
|
Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null
|
/// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +266,7 @@ public sealed class LicenseService : ILicenseService
|
|||||||
return license.CanDownload(version);
|
return license.CanDownload(version);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct)
|
public async Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var key = GetDecryptedKey();
|
var key = GetDecryptedKey();
|
||||||
if (string.IsNullOrEmpty(key)) return null;
|
if (string.IsNullOrEmpty(key)) return null;
|
||||||
@@ -261,6 +276,13 @@ public sealed class LicenseService : ILicenseService
|
|||||||
// PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
|
// PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
|
||||||
var encodedKey = Uri.EscapeDataString(key);
|
var encodedKey = Uri.EscapeDataString(key);
|
||||||
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
|
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
|
||||||
|
// Passe le filename attendu pour disambiguer les channels sur les manifests
|
||||||
|
// multi-entrée par version. Si null/empty, le serveur retombe sur "1re entrée
|
||||||
|
// matchant le numéro" (rétro-compat avec les vieux serveurs).
|
||||||
|
if (!string.IsNullOrWhiteSpace(expectedFilename))
|
||||||
|
{
|
||||||
|
url += "&filename=" + Uri.EscapeDataString(expectedFilename);
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas
|
// Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas
|
||||||
@@ -323,6 +345,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 +356,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 +414,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);
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ namespace PSLauncher.Core.Localization;
|
|||||||
/// session. Tout changement de langue nécessite un redémarrage du launcher
|
/// session. Tout changement de langue nécessite un redémarrage du launcher
|
||||||
/// (les bindings XAML x:Static ne refresh pas dynamiquement).
|
/// (les bindings XAML x:Static ne refresh pas dynamiquement).
|
||||||
///
|
///
|
||||||
/// Langues supportées : fr (par défaut), en, zh (Simplified), th, ar.
|
/// Langues supportées :
|
||||||
|
/// • fr (par défaut), en, zh (Simplified), th, ar : traduction complète historique
|
||||||
|
/// • es, de : ajoutées en v0.29.11, traduction progressive — les strings non encore
|
||||||
|
/// traduites tombent automatiquement sur l'anglais via T() (cf. fallback dans
|
||||||
|
/// <see cref="T(string,string,string,string,string,string?,string?)"/>).
|
||||||
/// Pour Arabic, FlowDirection doit aussi être basculé en RTL côté Window.
|
/// Pour Arabic, FlowDirection doit aussi être basculé en RTL côté Window.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Strings
|
public static class Strings
|
||||||
@@ -27,6 +31,8 @@ public static class Strings
|
|||||||
("auto", "Automatique (langue du système)"),
|
("auto", "Automatique (langue du système)"),
|
||||||
("fr", "Français"),
|
("fr", "Français"),
|
||||||
("en", "English"),
|
("en", "English"),
|
||||||
|
("es", "Español"),
|
||||||
|
("de", "Deutsch"),
|
||||||
("zh", "中文"),
|
("zh", "中文"),
|
||||||
("th", "ไทย"),
|
("th", "ไทย"),
|
||||||
("ar", "العربية"),
|
("ar", "العربية"),
|
||||||
@@ -39,7 +45,7 @@ public static class Strings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static void Init(string? configValue)
|
public static void Init(string? configValue)
|
||||||
{
|
{
|
||||||
var supported = new[] { "fr", "en", "zh", "th", "ar" };
|
var supported = new[] { "fr", "en", "es", "de", "zh", "th", "ar" };
|
||||||
var v = configValue?.Trim().ToLowerInvariant() ?? "auto";
|
var v = configValue?.Trim().ToLowerInvariant() ?? "auto";
|
||||||
|
|
||||||
if (v == "auto" || string.IsNullOrEmpty(v) || Array.IndexOf(supported, v) < 0)
|
if (v == "auto" || string.IsNullOrEmpty(v) || Array.IndexOf(supported, v) < 0)
|
||||||
@@ -62,9 +68,21 @@ public static class Strings
|
|||||||
catch { /* culture inconnue, on garde le défaut */ }
|
catch { /* culture inconnue, on garde le défaut */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string T(string fr, string en, string zh, string th, string ar) => _lang switch
|
/// <summary>
|
||||||
|
/// Helper de localisation. Les 5 premiers paramètres (fr/en/zh/th/ar) sont
|
||||||
|
/// requis = chaque string DOIT exister dans ces langues (traduction historique
|
||||||
|
/// complète). Les 2 derniers (es, de) sont OPTIONNELS — si non fournis, on
|
||||||
|
/// retombe sur l'anglais. Stratégie progressive : on traduit en es/de
|
||||||
|
/// uniquement les strings les plus visibles (top bar, actions principales,
|
||||||
|
/// états licenses, errors common) ; le reste reste en anglais pour les
|
||||||
|
/// utilisateurs es/de tant qu'on n'a pas traduit. Mieux qu'un crash ou
|
||||||
|
/// une chaîne vide, et l'anglais est globalement lisible par ces locuteurs.
|
||||||
|
/// </summary>
|
||||||
|
private static string T(string fr, string en, string zh, string th, string ar, string? es = null, string? de = null) => _lang switch
|
||||||
{
|
{
|
||||||
"en" => en,
|
"en" => en,
|
||||||
|
"es" => es ?? en, // fallback English si pas encore traduit
|
||||||
|
"de" => de ?? en, // idem
|
||||||
"zh" => zh,
|
"zh" => zh,
|
||||||
"th" => th,
|
"th" => th,
|
||||||
"ar" => ar,
|
"ar" => ar,
|
||||||
@@ -72,34 +90,36 @@ public static class Strings
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ==================== TOP BAR ====================
|
// ==================== TOP BAR ====================
|
||||||
public static string TopBarCheckUpdates => T("🔄 Vérifier les MAJ", "🔄 Check for updates", "🔄 检查更新", "🔄 ตรวจสอบการอัปเดต", "🔄 التحقق من التحديثات");
|
public static string TopBarCheckUpdates => T("🔄 Vérifier les MAJ", "🔄 Check for updates", "🔄 检查更新", "🔄 ตรวจสอบการอัปเดต", "🔄 التحقق من التحديثات", "🔄 Buscar actualizaciones", "🔄 Updates suchen");
|
||||||
public static string TopBarSettings => T("⚙ Paramètres", "⚙ Settings", "⚙ 设置", "⚙ ตั้งค่า", "⚙ الإعدادات");
|
public static string TopBarSettings => T("⚙ Paramètres", "⚙ Settings", "⚙ 设置", "⚙ ตั้งค่า", "⚙ الإعدادات", "⚙ Ajustes", "⚙ Einstellungen");
|
||||||
|
|
||||||
// ==================== BODY ====================
|
// ==================== BODY ====================
|
||||||
public static string BodyVersions => T("Versions", "Versions", "版本", "เวอร์ชัน", "النسخ");
|
public static string BodyVersions => T("Versions", "Versions", "版本", "เวอร์ชัน", "النسخ", "Versiones", "Versionen");
|
||||||
public static string BodyVersionsHint => T(
|
public static string BodyVersionsHint => T(
|
||||||
"Lance, installe ou supprime une version. Les versions installées et celles disponibles sur le serveur cohabitent.",
|
"Lance, installe ou supprime une version. Les versions installées et celles disponibles sur le serveur cohabitent.",
|
||||||
"Launch, install or remove a version. Installed and available versions can coexist.",
|
"Launch, install or remove a version. Installed and available versions can coexist.",
|
||||||
"启动、安装或删除版本。已安装的版本与服务器上可用的版本可以共存。",
|
"启动、安装或删除版本。已安装的版本与服务器上可用的版本可以共存。",
|
||||||
"เปิด ติดตั้ง หรือลบเวอร์ชัน เวอร์ชันที่ติดตั้งและที่มีในเซิร์ฟเวอร์อยู่ร่วมกันได้",
|
"เปิด ติดตั้ง หรือลบเวอร์ชัน เวอร์ชันที่ติดตั้งและที่มีในเซิร์ฟเวอร์อยู่ร่วมกันได้",
|
||||||
"تشغيل أو تثبيت أو إزالة نسخة. النسخ المثبتة والمتاحة على الخادم يمكن أن تتعايش."
|
"تشغيل أو تثبيت أو إزالة نسخة. النسخ المثبتة والمتاحة على الخادم يمكن أن تتعايش.",
|
||||||
|
"Inicia, instala o elimina una versión. Las versiones instaladas y las disponibles en el servidor pueden coexistir.",
|
||||||
|
"Starte, installiere oder entferne eine Version. Installierte und verfügbare Versionen können koexistieren."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static string FeaturedCurrent => T("VERSION COURANTE", "CURRENT VERSION", "当前版本", "เวอร์ชันปัจจุบัน", "النسخة الحالية");
|
public static string FeaturedCurrent => T("VERSION COURANTE", "CURRENT VERSION", "当前版本", "เวอร์ชันปัจจุบัน", "النسخة الحالية", "VERSIÓN ACTUAL", "AKTUELLE VERSION");
|
||||||
public static string SectionOther => T("AUTRES VERSIONS", "OTHER VERSIONS", "其他版本", "เวอร์ชันอื่นๆ", "نسخ أخرى");
|
public static string SectionOther => T("AUTRES VERSIONS", "OTHER VERSIONS", "其他版本", "เวอร์ชันอื่นๆ", "نسخ أخرى", "OTRAS VERSIONES", "ANDERE VERSIONEN");
|
||||||
public static string ReleasedOn => T("Sortie le ", "Released ", "发布于 ", "เผยแพร่เมื่อ ", "صدر في ");
|
public static string ReleasedOn => T("Sortie le ", "Released ", "发布于 ", "เผยแพร่เมื่อ ", "صدر في ", "Publicado el ", "Veröffentlicht am ");
|
||||||
|
|
||||||
// ==================== STATUS BADGES ====================
|
// ==================== STATUS BADGES ====================
|
||||||
public static string StatusInstalled => T("● Installée", "● Installed", "● 已安装", "● ติดตั้งแล้ว", "● مثبتة");
|
public static string StatusInstalled => T("● Installée", "● Installed", "● 已安装", "● ติดตั้งแล้ว", "● مثبتة", "● Instalada", "● Installiert");
|
||||||
public static string StatusAvailable => T("○ Disponible", "○ Available", "○ 可用", "○ พร้อมใช้งาน", "○ متاحة");
|
public static string StatusAvailable => T("○ Disponible", "○ Available", "○ 可用", "○ พร้อมใช้งาน", "○ متاحة", "○ Disponible", "○ Verfügbar");
|
||||||
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…");
|
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…", "⬇ Descargando…", "⬇ Wird heruntergeladen…");
|
||||||
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…");
|
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…", "📦 Instalando…", "📦 Wird installiert…");
|
||||||
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
|
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…", "🗑 Eliminando…", "🗑 Wird entfernt…");
|
||||||
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…");
|
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…", "🔍 Verificando…", "🔍 Wird überprüft…");
|
||||||
|
|
||||||
// ==================== BETA BADGE ====================
|
// ==================== BETA BADGE ====================
|
||||||
/// <summary>Texte affiché dans la pill orange à côté de la version. Court et localisé.</summary>
|
/// <summary>Texte affiché dans la pill orange à côté de la version. Court et localisé.</summary>
|
||||||
public static string BetaBadgeLabel => T("BÊTA", "BETA", "测试版", "เบต้า", "تجريبي");
|
public static string BetaBadgeLabel => T("BÊTA", "BETA", "测试版", "เบต้า", "تجريبي", "BETA", "BETA");
|
||||||
|
|
||||||
/// <summary>Tooltip par défaut quand la version est tag BÊTA mais sans note des testeurs.</summary>
|
/// <summary>Tooltip par défaut quand la version est tag BÊTA mais sans note des testeurs.</summary>
|
||||||
public static string BetaBadgeTooltipDefault => T(
|
public static string BetaBadgeTooltipDefault => T(
|
||||||
@@ -120,17 +140,17 @@ public static class Strings
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ==================== ACTIONS ====================
|
// ==================== ACTIONS ====================
|
||||||
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل", "▶ Iniciar", "▶ Starten");
|
||||||
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل");
|
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل", "▶ INICIAR", "▶ STARTEN");
|
||||||
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
|
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت", "⬇ Instalar", "⬇ Installieren");
|
||||||
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
|
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت", "⬇ INSTALAR", "⬇ INSTALLIEREN");
|
||||||
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء");
|
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء", "Cancelar", "Abbrechen");
|
||||||
public static string ActionOk => T("OK", "OK", "确定", "ตกลง", "موافق");
|
public static string ActionOk => T("OK", "OK", "确定", "ตกลง", "موافق", "OK", "OK");
|
||||||
public static string ActionYes => T("Oui", "Yes", "是", "ใช่", "نعم");
|
public static string ActionYes => T("Oui", "Yes", "是", "ใช่", "نعم", "Sí", "Ja");
|
||||||
public static string ActionNo => T("Non", "No", "否", "ไม่", "لا");
|
public static string ActionNo => T("Non", "No", "否", "ไม่", "لا", "No", "Nein");
|
||||||
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً");
|
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً", "Más tarde", "Später");
|
||||||
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ");
|
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ", "Guardar", "Speichern");
|
||||||
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق");
|
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق", "Cerrar", "Schließen");
|
||||||
// Affiché sur le bouton install d'une version dont le minLicenseDate dépasse l'entitlement de la license.
|
// Affiché sur le bouton install d'une version dont le minLicenseDate dépasse l'entitlement de la license.
|
||||||
// Concrètement : ta license expire le 2024-12-31 mais cette version a été sortie le 2025-03-15 → tu ne peux pas la télécharger.
|
// Concrètement : ta license expire le 2024-12-31 mais cette version a été sortie le 2025-03-15 → tu ne peux pas la télécharger.
|
||||||
// Tu peux toujours installer/lancer une version antérieure couverte par ta période de license.
|
// Tu peux toujours installer/lancer une version antérieure couverte par ta période de license.
|
||||||
@@ -258,6 +278,24 @@ public static class Strings
|
|||||||
// ==================== UPDATE ====================
|
// ==================== UPDATE ====================
|
||||||
public static string UpdateAvailableTitle => T("Mise à jour disponible", "Update available", "有可用更新", "มีการอัปเดต", "تحديث متاح");
|
public static string UpdateAvailableTitle => T("Mise à jour disponible", "Update available", "有可用更新", "มีการอัปเดต", "تحديث متاح");
|
||||||
public static string UpdateDownload => T("⬇ Télécharger", "⬇ Download", "⬇ 下载", "⬇ ดาวน์โหลด", "⬇ تنزيل");
|
public static string UpdateDownload => T("⬇ Télécharger", "⬇ Download", "⬇ 下载", "⬇ ดาวน์โหลด", "⬇ تنزيل");
|
||||||
|
public static string UpdatePreserveSaveGames => T(
|
||||||
|
"Conserver les sauvegardes et replays de la version précédente",
|
||||||
|
"Keep save games and replays from the previous version",
|
||||||
|
"保留上一版本的存档和录像",
|
||||||
|
"เก็บไฟล์เซฟและรีเพลย์จากเวอร์ชันก่อนหน้า",
|
||||||
|
"الاحتفاظ بحفظات اللعبة والإعادات من النسخة السابقة",
|
||||||
|
"Conservar las partidas guardadas y repeticiones de la versión anterior",
|
||||||
|
"Spielstände und Replays der vorherigen Version beibehalten"
|
||||||
|
);
|
||||||
|
public static string UpdatePreserveSaveGamesTooltip => T(
|
||||||
|
"Copie les fichiers .sav (PROSERVE_UE_*/Saved/SaveGames) ET les replays .replay (PROSERVE_UE_*/Saved/Demos) de la version la plus récente déjà installée vers la nouvelle installation, après extraction. Si aucune version précédente n'est trouvée, l'option est sans effet.",
|
||||||
|
"Copies .sav files (PROSERVE_UE_*/Saved/SaveGames) AND .replay files (PROSERVE_UE_*/Saved/Demos) from the most recent already-installed version into the new install, after extraction. If no previous version is found, the option has no effect.",
|
||||||
|
"在解压后将最近已安装版本的 .sav 存档 (PROSERVE_UE_*/Saved/SaveGames) 和 .replay 录像 (PROSERVE_UE_*/Saved/Demos) 复制到新安装中。如果未找到之前的版本,则此选项无效。",
|
||||||
|
"คัดลอกไฟล์ .sav (PROSERVE_UE_*/Saved/SaveGames) และไฟล์ .replay (PROSERVE_UE_*/Saved/Demos) จากเวอร์ชันที่ติดตั้งล่าสุดไปยังการติดตั้งใหม่ หลังจากการแตกไฟล์ หากไม่พบเวอร์ชันก่อนหน้า ตัวเลือกนี้จะไม่มีผล",
|
||||||
|
"ينسخ ملفات .sav (PROSERVE_UE_*/Saved/SaveGames) وملفات .replay (PROSERVE_UE_*/Saved/Demos) من أحدث نسخة مثبتة بالفعل إلى التثبيت الجديد، بعد الاستخراج. إذا لم يتم العثور على نسخة سابقة، فلن يكون لهذا الخيار أي تأثير.",
|
||||||
|
"Copia los archivos .sav (PROSERVE_UE_*/Saved/SaveGames) y los archivos .replay (PROSERVE_UE_*/Saved/Demos) de la versión más reciente ya instalada en la nueva instalación, tras la extracción. Si no se encuentra una versión anterior, la opción no surte efecto.",
|
||||||
|
"Kopiert die .sav-Dateien (PROSERVE_UE_*/Saved/SaveGames) UND .replay-Dateien (PROSERVE_UE_*/Saved/Demos) der zuletzt installierten Version nach dem Entpacken in die neue Installation. Wird keine vorherige Version gefunden, hat die Option keine Wirkung."
|
||||||
|
);
|
||||||
public static string LauncherUpdateTitle => T("Mise à jour du launcher", "Launcher update", "启动器更新", "อัปเดต Launcher", "تحديث المُشغِّل");
|
public static string LauncherUpdateTitle => T("Mise à jour du launcher", "Launcher update", "启动器更新", "อัปเดต Launcher", "تحديث المُشغِّل");
|
||||||
public static string LauncherUpdateAvailable => T("Mise à jour du launcher disponible", "Launcher update available", "启动器更新可用", "มีการอัปเดต Launcher", "تحديث المُشغِّل متاح");
|
public static string LauncherUpdateAvailable => T("Mise à jour du launcher disponible", "Launcher update available", "启动器更新可用", "มีการอัปเดต Launcher", "تحديث المُشغِّل متاح");
|
||||||
public static string LauncherUpdateBody => T(
|
public static string LauncherUpdateBody => T(
|
||||||
@@ -270,15 +308,25 @@ public static class Strings
|
|||||||
public static string LauncherUpdateNow => T("⬇ Mettre à jour", "⬇ Update now", "⬇ 立即更新", "⬇ อัปเดตเดี๋ยวนี้", "⬇ تحديث الآن");
|
public static string LauncherUpdateNow => T("⬇ Mettre à jour", "⬇ Update now", "⬇ 立即更新", "⬇ อัปเดตเดี๋ยวนี้", "⬇ تحديث الآن");
|
||||||
|
|
||||||
// ==================== MESSAGEBOX TITLES ====================
|
// ==================== MESSAGEBOX TITLES ====================
|
||||||
public static string MsgBoxError => T("Erreur", "Error", "错误", "ข้อผิดพลาด", "خطأ");
|
public static string MsgBoxError => T("Erreur", "Error", "错误", "ข้อผิดพลาด", "خطأ", "Error", "Fehler");
|
||||||
public static string MsgBoxLaunchError => T("Erreur de lancement", "Launch error", "启动错误", "เปิดใช้งานล้มเหลว", "خطأ في التشغيل");
|
public static string MsgBoxLaunchError => T("Erreur de lancement", "Launch error", "启动错误", "เปิดใช้งานล้มเหลว", "خطأ في التشغيل", "Error al iniciar", "Startfehler");
|
||||||
public static string MsgBoxConfirm => T("Confirmer", "Confirm", "确认", "ยืนยัน", "تأكيد");
|
public static string MsgBoxConfirm => T("Confirmer", "Confirm", "确认", "ยืนยัน", "تأكيد", "Confirmar", "Bestätigen");
|
||||||
public static string MsgBoxInfo => T("Information", "Information", "信息", "ข้อมูล", "معلومة");
|
public static string MsgBoxInfo => T("Information", "Information", "信息", "ข้อมูล", "معلومة", "Información", "Information");
|
||||||
public static string MsgBoxPatience => T("Patience", "Please wait", "请稍候", "โปรดรอ", "يرجى الانتظار");
|
public static string MsgBoxPatience => T("Patience", "Please wait", "请稍候", "โปรดรอ", "يرجى الانتظار", "Por favor espera", "Bitte warten");
|
||||||
public static string MsgBoxLanguageChange => T("Changement de langue", "Language change", "更改语言", "เปลี่ยนภาษา", "تغيير اللغة");
|
public static string MsgBoxLanguageChange => T("Changement de langue", "Language change", "更改语言", "เปลี่ยนภาษา", "تغيير اللغة", "Cambio de idioma", "Sprachänderung");
|
||||||
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار");
|
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار", "Notas de versión", "Versionshinweise");
|
||||||
|
|
||||||
// ==================== MESSAGEBOX MESSAGES ====================
|
// ==================== MESSAGEBOX MESSAGES ====================
|
||||||
|
public static string MsgInstallCollision(string version, string folderName) => T(
|
||||||
|
$"Impossible d'installer v{version} : le dossier « {folderName} » est déjà occupé par une autre édition (channel différent) de cette version.\n\nPour éviter d'écraser silencieusement l'install existante, configure un « installFolderTemplate » distinct côté backoffice pour cette entrée (ex : « PROSERVE-firefighter v{{version}} »), puis relance l'install.",
|
||||||
|
$"Cannot install v{version}: the folder \"{folderName}\" is already used by another edition (different channel) of this version.\n\nTo avoid silently overwriting the existing install, configure a distinct \"installFolderTemplate\" for this entry in the backoffice (e.g. \"PROSERVE-firefighter v{{version}}\"), then retry the install.",
|
||||||
|
$"无法安装 v{version}:文件夹 \"{folderName}\" 已被此版本的另一个版本(不同频道)占用。\n\n为避免静默覆盖现有安装,请在后台为该条目配置不同的 \"installFolderTemplate\"(例如 \"PROSERVE-firefighter v{{version}}\"),然后重试安装。",
|
||||||
|
$"ไม่สามารถติดตั้ง v{version}: โฟลเดอร์ \"{folderName}\" ถูกใช้แล้วโดยเวอร์ชันอื่น (channel ต่างกัน) ของเวอร์ชันนี้\n\nเพื่อหลีกเลี่ยงการเขียนทับการติดตั้งที่มีอยู่โดยไม่แจ้ง โปรดกำหนดค่า \"installFolderTemplate\" ที่แตกต่างกันสำหรับรายการนี้ในหลังบ้าน (เช่น \"PROSERVE-firefighter v{{version}}\") จากนั้นลองติดตั้งใหม่",
|
||||||
|
$"لا يمكن تثبيت v{version}: المجلد \"{folderName}\" مستخدم بالفعل بواسطة إصدار آخر (قناة مختلفة) من هذه النسخة.\n\nلتجنب الكتابة فوق التثبيت الموجود دون تنبيه، قم بإعداد \"installFolderTemplate\" مميز لهذا الإدخال في لوحة الإدارة (مثال \"PROSERVE-firefighter v{{version}}\")، ثم أعد المحاولة.",
|
||||||
|
$"No se puede instalar v{version}: la carpeta «{folderName}» ya está ocupada por otra edición (canal distinto) de esta versión.\n\nPara evitar sobrescribir la instalación existente sin aviso, configura un «installFolderTemplate» distinto para esta entrada en el backoffice (por ejemplo, «PROSERVE-firefighter v{{version}}»), y luego reintenta la instalación.",
|
||||||
|
$"v{version} kann nicht installiert werden: Der Ordner „{folderName}\" wird bereits von einer anderen Ausgabe (anderer Channel) dieser Version verwendet.\n\nUm ein stilles Überschreiben der bestehenden Installation zu vermeiden, konfigurieren Sie im Backoffice ein anderes „installFolderTemplate\" für diesen Eintrag (z. B. „PROSERVE-firefighter v{{version}}\") und wiederholen Sie die Installation."
|
||||||
|
);
|
||||||
|
|
||||||
public static string MsgBusy => T(
|
public static string MsgBusy => T(
|
||||||
"Une autre opération est déjà en cours.",
|
"Une autre opération est déjà en cours.",
|
||||||
"Another operation is already in progress.",
|
"Another operation is already in progress.",
|
||||||
@@ -311,6 +359,107 @@ public static class Strings
|
|||||||
$"فشل التنزيل أو التثبيت:\n\n{detail}"
|
$"فشل التنزيل أو التثبيت:\n\n{detail}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- SHA-256 mismatch après DL : message ciblé avec source + action ----
|
||||||
|
public static string SourceOvh => T("OVH (CDN public)", "OVH (public CDN)", "OVH(公共 CDN)", "OVH (CDN สาธารณะ)", "OVH (CDN عام)");
|
||||||
|
public static string SourcePeer(string host) => T(
|
||||||
|
$"peer LAN « {host} »", $"LAN peer « {host} »", $"局域网节点 « {host} »",
|
||||||
|
$"LAN peer « {host} »", $"عقدة LAN « {host} »");
|
||||||
|
public static string MsgBoxShaMismatch => T(
|
||||||
|
"Fichier téléchargé corrompu ou obsolète",
|
||||||
|
"Downloaded file corrupted or outdated",
|
||||||
|
"下载的文件已损坏或过时",
|
||||||
|
"ไฟล์ที่ดาวน์โหลดเสียหายหรือล้าสมัย",
|
||||||
|
"الملف المنزّل تالف أو قديم"
|
||||||
|
);
|
||||||
|
public static string MsgShaMismatchBody(string version, string sourceLabel, string expectedSha, string computedSha) => T(
|
||||||
|
$"Le ZIP de v{version} a été téléchargé depuis {sourceLabel}, mais son SHA-256 ne correspond pas à celui attendu par le manifest.\n\nAttendu (manifest) : {expectedSha}\nCalculé (téléchargé) : {computedSha}\n\nCauses probables :\n • Le cache CDN OVH n'a pas encore expiré après un re-build serveur (TTL 24-72 h) → l'URL est correcte mais le contenu servi est l'ancien\n • L'admin a re-uploadé le ZIP mais n'a pas cliqué « 🔁 Hasher les versions + signer » après → le manifest référence l'ancien SHA\n • SFTP a préservé le mtime du fichier → le cache .hashcache.json du serveur considère le ZIP inchangé et skip le re-hash\n\nLe cache local pour cette version a été automatiquement purgé. Actions à essayer dans l'ordre :\n 1. Côté admin serveur : « 🔁 Hasher les versions + signer » (avec « Force re-hash » si nécessaire)\n 2. Côté launcher : « ↻ Forcer re-téléchargement » dans le menu \"…\" puis Installer\n 3. Si toujours échec : compare le SHA attendu ci-dessus avec le sha256 calculé manuellement sur ton ZIP serveur (sha256sum / certutil)",
|
||||||
|
$"The v{version} ZIP was downloaded from {sourceLabel}, but its SHA-256 does not match the manifest's expected value.\n\nExpected (manifest) : {expectedSha}\nComputed (downloaded): {computedSha}\n\nLikely causes:\n • OVH CDN cache hasn't expired yet after a server rebuild (TTL 24-72h) → URL is correct but stale content is served\n • Admin re-uploaded the ZIP but didn't click « 🔁 Hash + sign » after → manifest references old SHA\n • SFTP preserved the file mtime → server-side .hashcache.json considers the ZIP unchanged and skips re-hashing\n\nLocal cache for this version was automatically purged. Actions to try in order:\n 1. Server-side admin: « 🔁 Hash + sign » (with « Force re-hash » if needed)\n 2. Launcher: « ↻ Force re-download » in the \"…\" menu then Install\n 3. Still failing: compare the expected SHA above with the sha256 manually computed on your server ZIP (sha256sum / certutil)",
|
||||||
|
$"v{version} 的 ZIP 是从 {sourceLabel} 下载的,但其 SHA-256 与清单中预期的值不匹配。\n\n预期(清单):{expectedSha}\n计算(下载):{computedSha}\n\n可能的原因:\n • 服务器重建后 OVH CDN 缓存尚未过期(TTL 24-72 小时)\n • 管理员重新上传 ZIP 后未点击「🔁 哈希 + 签名」→ 清单引用旧 SHA\n • SFTP 保留了文件 mtime → 服务器端缓存认为 ZIP 未变更并跳过重新哈希\n\n此版本的本地缓存已自动清除。",
|
||||||
|
$"ZIP ของ v{version} ดาวน์โหลดจาก {sourceLabel} แต่ SHA-256 ไม่ตรงกับค่าที่คาดหวังจาก manifest\n\nคาดหวัง (manifest) : {expectedSha}\nคำนวณ (ดาวน์โหลด) : {computedSha}\n\nสาเหตุที่เป็นไปได้:\n • แคช OVH CDN ยังไม่หมดอายุ\n • Admin อัปโหลด ZIP ใหม่แต่ไม่กด « 🔁 Hash + sign »\n • SFTP รักษา mtime ไฟล์\n\nแคชในเครื่องถูกล้างอัตโนมัติแล้ว",
|
||||||
|
$"تم تنزيل ZIP v{version} من {sourceLabel}، لكن SHA-256 لا يطابق البيان.\n\nمتوقع (البيان) : {expectedSha}\nمحسوب (المنزل) : {computedSha}\n\nالأسباب المحتملة:\n • لم تنته ذاكرة CDN OVH بعد\n • أعاد المسؤول رفع ZIP دون النقر « 🔁 Hash + sign »\n • حافظ SFTP على mtime الملف\n\nتم مسح ذاكرة التخزين المؤقت المحلية تلقائياً."
|
||||||
|
);
|
||||||
|
public static string StatusShaMismatch(string version) => T(
|
||||||
|
$"⚠ v{version} : SHA-256 invalide — cache purgé, réessaye",
|
||||||
|
$"⚠ v{version}: SHA-256 invalid — cache purged, retry",
|
||||||
|
$"⚠ v{version}:SHA-256 无效 — 缓存已清除,请重试",
|
||||||
|
$"⚠ v{version}: SHA-256 ไม่ถูกต้อง — แคชถูกล้าง ลองใหม่",
|
||||||
|
$"⚠ v{version}: SHA-256 غير صالح — تم مسح الذاكرة، أعد المحاولة"
|
||||||
|
);
|
||||||
|
// ---- HTTP 404 mid-DL : manifest local obsolète (fichier renommé serveur) ----
|
||||||
|
public static string MsgBoxStaleManifest => T(
|
||||||
|
"Manifest local obsolète",
|
||||||
|
"Local manifest outdated",
|
||||||
|
"本地清单已过时",
|
||||||
|
"Manifest ในเครื่องล้าสมัย",
|
||||||
|
"البيان المحلي قديم"
|
||||||
|
);
|
||||||
|
public static string MsgStaleManifestBody(string version) => T(
|
||||||
|
$"Le fichier ZIP de v{version} a été renommé ou supprimé côté serveur (typique d'un re-upload pour invalider le cache OVH CDN).\n\nLe launcher a automatiquement re-fetché le manifest depuis le serveur — la nouvelle URL devrait maintenant être connue.\n\nRe-clique « Installer » pour réessayer avec l'URL fraîche.",
|
||||||
|
$"The v{version} ZIP file was renamed or deleted on the server (typical of a re-upload to invalidate OVH CDN cache).\n\nThe launcher has automatically re-fetched the manifest from the server — the new URL should now be known.\n\nClick « Install » again to retry with the fresh URL.",
|
||||||
|
$"v{version} 的 ZIP 文件已在服务器端重命名或删除(典型的重新上传以使 OVH CDN 缓存失效)。\n\n启动器已自动从服务器重新获取清单 — 新 URL 现在应该已知。\n\n再次点击 \"安装\" 以使用新 URL 重试。",
|
||||||
|
$"ไฟล์ ZIP ของ v{version} ถูกเปลี่ยนชื่อหรือลบบนเซิร์ฟเวอร์ (ทั่วไปจากการอัปโหลดใหม่เพื่อล้างแคช OVH CDN)\n\nLauncher ดึง manifest จากเซิร์ฟเวอร์อัตโนมัติแล้ว — ตอนนี้ควรรู้ URL ใหม่\n\nคลิก \"ติดตั้ง\" อีกครั้งเพื่อลองด้วย URL ใหม่",
|
||||||
|
$"تم إعادة تسمية أو حذف ملف ZIP الخاص بـ v{version} من جانب الخادم (نموذجي لإعادة الرفع لإبطال ذاكرة التخزين المؤقت لـ OVH CDN).\n\nقام المشغل بإعادة جلب البيان تلقائياً من الخادم — يجب أن يكون عنوان URL الجديد معروفاً الآن.\n\nانقر فوق \"تثبيت\" مرة أخرى لإعادة المحاولة باستخدام عنوان URL الجديد."
|
||||||
|
);
|
||||||
|
public static string StatusManifestRefreshed => T(
|
||||||
|
"Manifest rafraîchi — re-clique Installer",
|
||||||
|
"Manifest refreshed — click Install again",
|
||||||
|
"清单已刷新 — 再次点击安装",
|
||||||
|
"Manifest รีเฟรชแล้ว — คลิกติดตั้งอีกครั้ง",
|
||||||
|
"تم تحديث البيان — انقر فوق تثبيت مرة أخرى"
|
||||||
|
);
|
||||||
|
// ---- HTTP 416 : range demandé hors du fichier (file plus petit que sizeBytes) ----
|
||||||
|
public static string MsgBoxSizeMismatch => T(
|
||||||
|
"Taille de fichier incohérente sur le serveur",
|
||||||
|
"Server file size mismatch",
|
||||||
|
"服务器文件大小不一致",
|
||||||
|
"ขนาดไฟล์เซิร์ฟเวอร์ไม่ตรงกัน",
|
||||||
|
"حجم الملف على الخادم غير متطابق"
|
||||||
|
);
|
||||||
|
public static string StatusSizeMismatch(string version) => T(
|
||||||
|
$"⚠ v{version} : taille du ZIP serveur ≠ manifest — re-upload nécessaire",
|
||||||
|
$"⚠ v{version}: server ZIP size ≠ manifest — re-upload needed",
|
||||||
|
$"⚠ v{version}:服务器 ZIP 大小 ≠ 清单 — 需要重新上传",
|
||||||
|
$"⚠ v{version}: ขนาด ZIP เซิร์ฟเวอร์ ≠ manifest — ต้องอัปโหลดใหม่",
|
||||||
|
$"⚠ v{version}: حجم ZIP الخادم ≠ البيان — مطلوب إعادة الرفع"
|
||||||
|
);
|
||||||
|
|
||||||
|
public static string StatusAutoRetryAfter404(string version) => T(
|
||||||
|
$"v{version} : manifest rafraîchi, re-lancement automatique du téléchargement…",
|
||||||
|
$"v{version}: manifest refreshed, auto-retrying download…",
|
||||||
|
$"v{version}:清单已刷新,自动重试下载…",
|
||||||
|
$"v{version}: รีเฟรช manifest แล้ว ลองดาวน์โหลดใหม่อัตโนมัติ…",
|
||||||
|
$"v{version}: تم تحديث البيان، إعادة محاولة التنزيل تلقائياً…"
|
||||||
|
);
|
||||||
|
public static string MsgServerSideStaleBody(string version) => T(
|
||||||
|
$"Le téléchargement de v{version} échoue toujours en 404 même APRÈS un refresh du manifest depuis le serveur.\n\nDiagnostic :\n • Le manifest serveur lui-même est obsolète — il référence l'ancien nom de fichier qui n'existe plus.\n • Action côté serveur requise : le manifest doit être mis à jour pour pointer vers le nouveau nom du ZIP (ou re-uploader avec l'ancien nom).\n\nVérifie le backoffice serveur. Une fois le manifest corrigé, clique « Vérifier les MAJ » puis « Installer ».",
|
||||||
|
$"v{version} download still fails with 404 EVEN AFTER refreshing the manifest from the server.\n\nDiagnosis:\n • The server-side manifest itself is outdated — it references the old filename which no longer exists.\n • Server-side action required: the manifest must be updated to point to the new ZIP filename (or re-upload with the old name).\n\nCheck the server backoffice. Once the manifest is fixed, click « Check Updates » then « Install ».",
|
||||||
|
$"v{version} 下载即使在从服务器刷新清单后仍因 404 失败。\n\n诊断:\n • 服务器端清单本身已过时 — 它引用了不再存在的旧文件名。\n • 需要服务器端操作:清单必须更新以指向新的 ZIP 文件名(或以旧名称重新上传)。\n\n检查服务器后台。修复清单后,点击 \"检查更新\" 然后 \"安装\"。",
|
||||||
|
$"การดาวน์โหลด v{version} ยังคงล้มเหลวด้วย 404 แม้หลังจากรีเฟรช manifest จากเซิร์ฟเวอร์\n\nการวินิจฉัย:\n • Manifest ฝั่งเซิร์ฟเวอร์เองล้าสมัย — อ้างถึงชื่อไฟล์เก่าที่ไม่มีอยู่แล้ว\n • ต้องดำเนินการฝั่งเซิร์ฟเวอร์: manifest ต้องอัปเดตให้ชี้ไปยังชื่อ ZIP ใหม่ (หรืออัปโหลดใหม่ด้วยชื่อเก่า)\n\nตรวจสอบ backoffice เซิร์ฟเวอร์ เมื่อ manifest ถูกแก้ไขแล้ว ให้คลิก \"ตรวจสอบการอัปเดต\" จากนั้น \"ติดตั้ง\"",
|
||||||
|
$"لا يزال تنزيل v{version} يفشل بـ 404 حتى بعد تحديث البيان من الخادم.\n\nالتشخيص:\n • البيان من جانب الخادم نفسه قديم — يشير إلى اسم الملف القديم الذي لم يعد موجوداً.\n • مطلوب إجراء من جانب الخادم: يجب تحديث البيان للإشارة إلى اسم ZIP الجديد (أو إعادة الرفع بالاسم القديم).\n\nتحقق من backoffice الخادم. بعد إصلاح البيان، انقر فوق \"التحقق من التحديثات\" ثم \"تثبيت\"."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static string MenuForceFreshDownload => T(
|
||||||
|
"↻ Forcer re-téléchargement (vider cache local)",
|
||||||
|
"↻ Force re-download (purge local cache)",
|
||||||
|
"↻ 强制重新下载(清除本地缓存)",
|
||||||
|
"↻ บังคับดาวน์โหลดใหม่ (ล้างแคช)",
|
||||||
|
"↻ فرض إعادة التنزيل (مسح الذاكرة)"
|
||||||
|
);
|
||||||
|
public static string MsgBoxForceFresh => T(
|
||||||
|
"Forcer le re-téléchargement",
|
||||||
|
"Force re-download",
|
||||||
|
"强制重新下载",
|
||||||
|
"บังคับดาวน์โหลดใหม่",
|
||||||
|
"فرض إعادة التنزيل"
|
||||||
|
);
|
||||||
|
public static string MsgForceFreshConfirm(string version) => T(
|
||||||
|
$"Vider le cache local pour v{version} et forcer un re-téléchargement complet ?\n\nCela supprimera le ZIP cache + le sidecar SHA + tout partial de DL en cours pour cette version. Utile quand le SHA-256 a changé côté serveur et que la source précédente était obsolète.",
|
||||||
|
$"Purge the local cache for v{version} and force a full re-download?\n\nThis will delete the cached ZIP + SHA sidecar + any in-progress partial for this version. Useful when the server-side SHA-256 has changed and the previous source was stale.",
|
||||||
|
$"清除 v{version} 的本地缓存并强制完全重新下载?\n\n这将删除缓存的 ZIP + SHA sidecar + 此版本的任何进行中的部分下载。当服务器端 SHA-256 已更改且之前的源已过时时很有用。",
|
||||||
|
$"ล้างแคชในเครื่องสำหรับ v{version} และบังคับให้ดาวน์โหลดใหม่ทั้งหมด?\n\nสิ่งนี้จะลบ ZIP ที่แคช + SHA sidecar + การดาวน์โหลดบางส่วนที่กำลังดำเนินการสำหรับเวอร์ชันนี้ มีประโยชน์เมื่อ SHA-256 ฝั่งเซิร์ฟเวอร์เปลี่ยนแปลงและแหล่งที่มาก่อนหน้านี้ล้าสมัย",
|
||||||
|
$"مسح ذاكرة التخزين المؤقت المحلية لـ v{version} وفرض إعادة تنزيل كاملة؟\n\nسيؤدي هذا إلى حذف ZIP المخزن مؤقتاً + SHA sidecar + أي تنزيل جزئي قيد التقدم لهذا الإصدار. مفيد عندما يتم تغيير SHA-256 على جانب الخادم وكان المصدر السابق قديماً."
|
||||||
|
);
|
||||||
|
|
||||||
public static string MsgUninstallFailed(string detail) => T(
|
public static string MsgUninstallFailed(string detail) => T(
|
||||||
$"Suppression échouée : {detail}",
|
$"Suppression échouée : {detail}",
|
||||||
$"Uninstall failed: {detail}",
|
$"Uninstall failed: {detail}",
|
||||||
@@ -444,6 +593,16 @@ public static class Strings
|
|||||||
$"تم تثبيت v{version} بنجاح"
|
$"تم تثبيت v{version} بنجاح"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static string StatusCopyingSaveGames(string fromVersion, int fileCount) => T(
|
||||||
|
$"Copie des sauvegardes et replays depuis v{fromVersion} ({fileCount} fichier{(fileCount > 1 ? "s" : "")})…",
|
||||||
|
$"Copying save games and replays from v{fromVersion} ({fileCount} file{(fileCount > 1 ? "s" : "")})…",
|
||||||
|
$"正在从 v{fromVersion} 复制存档和录像({fileCount} 个文件)…",
|
||||||
|
$"กำลังคัดลอกไฟล์เซฟและรีเพลย์จาก v{fromVersion} ({fileCount} ไฟล์)…",
|
||||||
|
$"جارٍ نسخ حفظات اللعبة والإعادات من v{fromVersion} ({fileCount} ملف)…",
|
||||||
|
$"Copiando partidas guardadas y repeticiones desde v{fromVersion} ({fileCount} archivo{(fileCount > 1 ? "s" : "")})…",
|
||||||
|
$"Spielstände und Replays werden von v{fromVersion} kopiert ({fileCount} Datei{(fileCount > 1 ? "en" : "")})…"
|
||||||
|
);
|
||||||
|
|
||||||
public static string StatusUninstallingVersion(string version) => T(
|
public static string StatusUninstallingVersion(string version) => T(
|
||||||
$"Suppression v{version}…",
|
$"Suppression v{version}…",
|
||||||
$"Uninstalling v{version}…",
|
$"Uninstalling v{version}…",
|
||||||
@@ -625,6 +784,72 @@ public static class Strings
|
|||||||
public static string SettingsApiBackups => SettingsReportBackups;
|
public static string SettingsApiBackups => SettingsReportBackups;
|
||||||
public static string SettingsApiMaxBackups => SettingsReportMaxBackups;
|
public static string SettingsApiMaxBackups => SettingsReportMaxBackups;
|
||||||
|
|
||||||
|
// ---- SteamVR settings merge (tue SteamVR + Vive Business Streaming + merge JSON) ----
|
||||||
|
public static string StatusDeployingSteamVr(string version) => T(
|
||||||
|
$"Configuration SteamVR pour v{version}…",
|
||||||
|
$"Configuring SteamVR for v{version}…",
|
||||||
|
$"正在为 v{version} 配置 SteamVR…",
|
||||||
|
$"กำลังกำหนดค่า SteamVR สำหรับ v{version}…",
|
||||||
|
$"جارٍ تهيئة SteamVR لـ v{version}…"
|
||||||
|
);
|
||||||
|
public static string MsgBoxSteamVrMergeFailed => T(
|
||||||
|
"Configuration SteamVR échouée",
|
||||||
|
"SteamVR configuration failed",
|
||||||
|
"SteamVR 配置失败",
|
||||||
|
"การกำหนดค่า SteamVR ล้มเหลว",
|
||||||
|
"فشل تهيئة SteamVR"
|
||||||
|
);
|
||||||
|
public static string MsgSteamVrPreFlightTitle => T(
|
||||||
|
"Fermeture de SteamVR & Vive Business Streaming",
|
||||||
|
"Closing SteamVR & Vive Business Streaming",
|
||||||
|
"正在关闭 SteamVR 和 Vive Business Streaming",
|
||||||
|
"ปิด SteamVR และ Vive Business Streaming",
|
||||||
|
"إغلاق SteamVR و Vive Business Streaming"
|
||||||
|
);
|
||||||
|
public static string MsgSteamVrPreFlightBody(string processesPreview, int rootKeysChanging) => T(
|
||||||
|
$"{rootKeysChanging} bloc(s) de la configuration SteamVR vont être mis à jour.\n\nPour appliquer ces changements (mapping trackers, bindings), le launcher doit fermer ces programmes :\n\n • {processesPreview}\n\nIls pourront être relancés normalement après l'install. Continuer ?",
|
||||||
|
$"{rootKeysChanging} SteamVR config block(s) will be updated.\n\nTo apply these changes (tracker mappings, bindings), the launcher must close these programs:\n\n • {processesPreview}\n\nYou'll be able to relaunch them normally after the install. Proceed?",
|
||||||
|
$"将更新 {rootKeysChanging} 个 SteamVR 配置块。\n\n为应用这些更改(追踪器映射、绑定),启动器必须关闭这些程序:\n\n • {processesPreview}\n\n安装完成后您可以正常重新启动它们。继续吗?",
|
||||||
|
$"จะอัปเดตบล็อกการกำหนดค่า SteamVR {rootKeysChanging} บล็อก\n\nเพื่อใช้การเปลี่ยนแปลงเหล่านี้ (การจับคู่ตัวติดตาม การผูก) ตัวเปิดต้องปิดโปรแกรมเหล่านี้:\n\n • {processesPreview}\n\nคุณสามารถเปิดใหม่ได้ตามปกติหลังติดตั้ง ดำเนินการต่อ?",
|
||||||
|
$"سيتم تحديث {rootKeysChanging} كتلة (كتل) من تكوين SteamVR.\n\nلتطبيق هذه التغييرات (تعيينات أجهزة التتبع، الارتباطات)، يجب أن يغلق المشغل هذه البرامج:\n\n • {processesPreview}\n\nيمكنك إعادة تشغيلها بشكل طبيعي بعد التثبيت. متابعة؟"
|
||||||
|
);
|
||||||
|
public static string MsgSteamVrMergeFailed(string detail) => T(
|
||||||
|
$"Le merge des settings SteamVR a échoué :\n\n{detail}\n\nLa version PROSERVE est installée et fonctionnera, mais le mapping des trackers Vive Business Streaming pourrait être incorrect. Vérifie depuis SteamVR → Paramètres → Périphériques après le premier lancement.",
|
||||||
|
$"SteamVR settings merge failed:\n\n{detail}\n\nThe PROSERVE version is installed and will run, but Vive Business Streaming tracker bindings might be off. Verify from SteamVR → Settings → Devices after the first launch.",
|
||||||
|
$"SteamVR 设置合并失败:\n\n{detail}\n\nPROSERVE 版本已安装且将运行,但 Vive Business Streaming 跟踪器绑定可能不正确。首次启动后请通过 SteamVR → 设置 → 设备 进行检查。",
|
||||||
|
$"การรวมการตั้งค่า SteamVR ล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วและจะทำงาน แต่การจับคู่ตัวติดตาม Vive Business Streaming อาจไม่ถูกต้อง ตรวจสอบจาก SteamVR → การตั้งค่า → อุปกรณ์ หลังเปิดครั้งแรก",
|
||||||
|
$"فشل دمج إعدادات SteamVR:\n\n{detail}\n\nنسخة PROSERVE مثبتة وستعمل، لكن قد يكون ربط أجهزة تتبع Vive Business Streaming غير صحيح. تحقق من SteamVR ← الإعدادات ← الأجهزة بعد التشغيل الأول."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static string SettingsSteamVr => T(
|
||||||
|
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||||
|
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||||
|
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||||
|
"STEAMVR + VIVE BUSINESS STREAMING",
|
||||||
|
"STEAMVR + VIVE BUSINESS STREAMING"
|
||||||
|
);
|
||||||
|
public static string SettingsSteamVrAutoMerge => T(
|
||||||
|
"Fusionner steamvr.vrsettings à l'install (tue SteamVR + Vive Business Streaming, modifie le JSON, laisse SteamVR redémarrer au prochain lancement PROSERVE)",
|
||||||
|
"Merge steamvr.vrsettings on install (kills SteamVR + Vive Business Streaming, edits the JSON, lets SteamVR restart on next PROSERVE launch)",
|
||||||
|
"安装时合并 steamvr.vrsettings(终止 SteamVR + Vive Business Streaming,编辑 JSON,下次启动 PROSERVE 时让 SteamVR 重新启动)",
|
||||||
|
"รวม steamvr.vrsettings เมื่อติดตั้ง (ปิด SteamVR + Vive Business Streaming, แก้ JSON, ปล่อยให้ SteamVR เริ่มใหม่เมื่อเปิด PROSERVE ครั้งถัดไป)",
|
||||||
|
"دمج steamvr.vrsettings عند التثبيت (إنهاء SteamVR + Vive Business Streaming، تعديل JSON، السماح لـ SteamVR بإعادة التشغيل عند تشغيل PROSERVE التالي)"
|
||||||
|
);
|
||||||
|
public static string SettingsSteamVrSettingsPath => T(
|
||||||
|
"Chemin vers steamvr.vrsettings (vide = auto-détection via registre Steam)",
|
||||||
|
"Path to steamvr.vrsettings (empty = auto-detect via Steam registry)",
|
||||||
|
"steamvr.vrsettings 路径(空 = 通过 Steam 注册表自动检测)",
|
||||||
|
"พาธไปยัง steamvr.vrsettings (ว่าง = ตรวจหาอัตโนมัติผ่าน Steam registry)",
|
||||||
|
"مسار steamvr.vrsettings (فارغ = اكتشاف تلقائي عبر سجل Steam)"
|
||||||
|
);
|
||||||
|
public static string SettingsSteamVrProcesses => T(
|
||||||
|
"Processus à tuer avant le merge (un nom par ligne, sans .exe). Vive Business Streaming doit être en tête car il relance SteamVR.",
|
||||||
|
"Processes to kill before merging (one name per line, no .exe). Vive Business Streaming must be first because it auto-restarts SteamVR.",
|
||||||
|
"合并前要终止的进程(每行一个名称,无 .exe)。Vive Business Streaming 必须放在第一位,因为它会自动重启 SteamVR。",
|
||||||
|
"โพรเซสที่ต้องปิดก่อนรวม (หนึ่งชื่อต่อบรรทัด ไม่ใส่ .exe) Vive Business Streaming ต้องอยู่บนสุดเพราะมันรีสตาร์ท SteamVR อัตโนมัติ",
|
||||||
|
"العمليات التي يجب إنهاؤها قبل الدمج (اسم واحد لكل سطر، بدون .exe). يجب أن يكون Vive Business Streaming في المقدمة لأنه يعيد تشغيل SteamVR تلقائياً."
|
||||||
|
);
|
||||||
|
|
||||||
// ---- Cache LAN P2P ----
|
// ---- Cache LAN P2P ----
|
||||||
public static string StatusDownloadingFromPeer(string host, string version) => T(
|
public static string StatusDownloadingFromPeer(string host, string version) => T(
|
||||||
$"📡 Téléchargement de v{version} depuis {host} (LAN)…",
|
$"📡 Téléchargement de v{version} depuis {host} (LAN)…",
|
||||||
@@ -676,6 +901,176 @@ public static class Strings
|
|||||||
);
|
);
|
||||||
public static string MsgBoxLanCacheChange => T("Cache LAN P2P", "LAN P2P cache", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P");
|
public static string MsgBoxLanCacheChange => T("Cache LAN P2P", "LAN P2P cache", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P");
|
||||||
|
|
||||||
|
// ---- Redist Unreal (VC_redist, UEPrereqSetup, …) ----
|
||||||
|
public static string StatusInstallingRedists(string version) => T(
|
||||||
|
$"⚙️ Installation des composants requis pour v{version}…",
|
||||||
|
$"⚙️ Installing required components for v{version}…",
|
||||||
|
$"⚙️ 正在为 v{version} 安装必需组件…",
|
||||||
|
$"⚙️ กำลังติดตั้งส่วนประกอบที่จำเป็นสำหรับ v{version}…",
|
||||||
|
$"⚙️ جارٍ تثبيت المكونات المطلوبة للنسخة v{version}…"
|
||||||
|
);
|
||||||
|
public static string ProgressRedistInstalling(int done, int total, string filename) => T(
|
||||||
|
$"⚙️ Redist {done}/{total} : {filename}",
|
||||||
|
$"⚙️ Redist {done}/{total}: {filename}",
|
||||||
|
$"⚙️ Redist {done}/{total}:{filename}",
|
||||||
|
$"⚙️ Redist {done}/{total}: {filename}",
|
||||||
|
$"⚙️ Redist {done}/{total}: {filename}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Mode auto (auto-launch + auto-relaunch après exit) ----
|
||||||
|
public static string ActionAuto => T("AUTO", "AUTO", "自动", "อัตโนมัติ", "تلقائي", "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 → Arguments de lancement (défauts appliqués partout) ----
|
||||||
|
public static string SettingsLaunchArgs => T(
|
||||||
|
"ARGUMENTS DE LANCEMENT",
|
||||||
|
"LAUNCH ARGUMENTS",
|
||||||
|
"启动参数",
|
||||||
|
"อาร์กิวเมนต์การเปิด",
|
||||||
|
"وسائط التشغيل",
|
||||||
|
"ARGUMENTOS DE INICIO",
|
||||||
|
"STARTARGUMENTE"
|
||||||
|
);
|
||||||
|
public static string SettingsLaunchArgsHelp => T(
|
||||||
|
"Arguments CLI appliqués à CHAQUE lancement de PROSERVE (mode auto ou manuel). Les arguments du mode auto ci-dessous s'ajoutent en plus, uniquement pour la version désignée AUTO.",
|
||||||
|
"CLI arguments applied to EVERY PROSERVE launch (auto or manual). Auto mode arguments below are added on top, only for the version marked AUTO.",
|
||||||
|
"应用于每次 PROSERVE 启动的 CLI 参数(自动或手动)。下方的自动模式参数仅对标记为 AUTO 的版本额外附加。",
|
||||||
|
"อาร์กิวเมนต์ CLI ที่ใช้กับการเปิด PROSERVE ทุกครั้ง (อัตโนมัติหรือด้วยตนเอง) อาร์กิวเมนต์โหมดอัตโนมัติด้านล่างจะเพิ่มเฉพาะสำหรับเวอร์ชันที่ตั้งเป็น AUTO",
|
||||||
|
"وسائط CLI مطبقة على كل تشغيل لـ PROSERVE (تلقائي أو يدوي). وسائط الوضع التلقائي أدناه تُضاف فقط للنسخة المحددة AUTO.",
|
||||||
|
"Argumentos CLI aplicados a CADA inicio de PROSERVE (auto o manual). Los argumentos del modo auto de abajo se añaden encima solo para la versión marcada AUTO.",
|
||||||
|
"CLI-Argumente, die bei JEDEM Start von PROSERVE angewendet werden (Auto oder manuell). Die Auto-Modus-Argumente unten werden nur für die als AUTO markierte Version zusätzlich angehängt."
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,9 +146,16 @@ public sealed class ManifestService : IManifestService
|
|||||||
// Whitelist côté client aussi pour éviter des chars exotiques qui casseraient l'URL.
|
// Whitelist côté client aussi pour éviter des chars exotiques qui casseraient l'URL.
|
||||||
var channel = _channelProvider();
|
var channel = _channelProvider();
|
||||||
var baseUrl = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
|
var baseUrl = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
|
||||||
var url = !string.IsNullOrWhiteSpace(channel) && System.Text.RegularExpressions.Regex.IsMatch(channel!, "^[a-z0-9_-]{1,64}$")
|
// Query params : ?channel=X (filtrage par channel license) + &multiChannel=1
|
||||||
? $"{baseUrl}?channel={Uri.EscapeDataString(channel!)}"
|
// (opt-in au comportement v1.0.10+ : le serveur envoie plusieurs entries au
|
||||||
: baseUrl;
|
// même numéro de version sans dédup, le client sait afficher chacune sur
|
||||||
|
// sa row). Toujours envoyé — les serveurs anciens l'ignorent silencieusement.
|
||||||
|
var qs = new System.Text.StringBuilder("?multiChannel=1");
|
||||||
|
if (!string.IsNullOrWhiteSpace(channel) && System.Text.RegularExpressions.Regex.IsMatch(channel!, "^[a-z0-9_-]{1,64}$"))
|
||||||
|
{
|
||||||
|
qs.Append("&channel=").Append(Uri.EscapeDataString(channel!));
|
||||||
|
}
|
||||||
|
var url = baseUrl + qs.ToString();
|
||||||
using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
ovhCts.CancelAfter(OvhFetchTimeoutMs);
|
ovhCts.CancelAfter(OvhFetchTimeoutMs);
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
|
|||||||
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
|
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Première barrière : vérifier que la base configurée existe, sinon la
|
||||||
|
// créer. Sans ça la toute première install d'un PC neuf (base pas
|
||||||
|
// encore présente côté MySQL) plantait dès `OpenAsync` avec
|
||||||
|
// "Unknown database 'proserveapi'" et l'install s'arrêtait là.
|
||||||
|
await EnsureDatabaseExistsAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
||||||
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
|
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -187,6 +193,57 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
|
|||||||
return conn;
|
return conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ouvre une connexion SANS spécifier <c>Database</c> dans la connection
|
||||||
|
/// string, puis exécute <c>CREATE DATABASE IF NOT EXISTS</c>. Évite l'erreur
|
||||||
|
/// "Unknown database" à la toute première install d'un PC neuf où l'opérateur
|
||||||
|
/// n'a pas créé la base à la main.
|
||||||
|
///
|
||||||
|
/// L'identifier est quoté avec des backticks pour gérer les noms exotiques
|
||||||
|
/// (espaces, caractères spéciaux) ; on filtre quand même les backticks
|
||||||
|
/// pour éviter une injection si la config était trafiquée à la main.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnsureDatabaseExistsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.Database))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Database config has no Database name — cannot ensure-exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pwd = _passwordProvider() ?? string.Empty;
|
||||||
|
var csb = new MySqlConnectionStringBuilder
|
||||||
|
{
|
||||||
|
Server = cfg.Host,
|
||||||
|
Port = cfg.Port,
|
||||||
|
UserID = cfg.User,
|
||||||
|
Password = pwd,
|
||||||
|
// PAS de Database = on se connecte au serveur sans base sélectionnée.
|
||||||
|
// Le user mysql doit avoir CREATE DATABASE sinon ça échouera ici —
|
||||||
|
// dans ce cas l'opérateur sait que la base doit être créée à la main.
|
||||||
|
Pooling = false,
|
||||||
|
ConnectionTimeout = 10,
|
||||||
|
DefaultCommandTimeout = 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sanitize l'identifier : MySQL identifiers peuvent contenir n'importe
|
||||||
|
// quoi entre backticks, mais on rejette les backticks internes par
|
||||||
|
// sécurité (pas d'injection même si la config est falsifiée).
|
||||||
|
var safeDbName = cfg.Database.Replace("`", "");
|
||||||
|
var ddl = $"CREATE DATABASE IF NOT EXISTS `{safeDbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
|
||||||
|
|
||||||
|
await using var conn = new MySqlConnection(csb.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
await using var cmd = new MySqlCommand(ddl, conn);
|
||||||
|
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||||
|
// affected == 1 → base créée. affected == 0 → existait déjà.
|
||||||
|
if (affected > 0)
|
||||||
|
_logger.LogInformation("Created database `{Database}` (did not exist yet)", safeDbName);
|
||||||
|
else
|
||||||
|
_logger.LogDebug("Database `{Database}` already exists, no-op", safeDbName);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
|
private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
|
||||||
{
|
{
|
||||||
const string ddl = $"""
|
const string ddl = $"""
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,23 @@ public sealed class ReportToolDeployer : IReportToolDeployer
|
|||||||
|
|
||||||
if (!Directory.Exists(cfg.HtdocsRoot))
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
// Crée le dossier htdocs même si XAMPP n'est pas (encore) installé.
|
||||||
|
// Cas typique : PC fraichement déployé où XAMPP sera installé après.
|
||||||
|
// L'install PROSERVE peut tout de même s'exécuter — le déploiement
|
||||||
|
// produira un dossier prêt à servir dès qu'Apache sera up.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(cfg.HtdocsRoot);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
|
||||||
|
cfg.HtdocsRoot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
|
||||||
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||||
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var stagingTarget = target + ".new";
|
var stagingTarget = target + ".new";
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs
Normal file
120
src/PSLauncher.Core/SteamVr/ISteamVrSettingsDeployer.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
namespace PSLauncher.Core.SteamVr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fusionne le <c>steamvr.vrsettings</c> bundled dans le ZIP PROSERVE
|
||||||
|
/// (sous-dossier <c>_steamvr/</c>) dans le fichier SteamVR de l'utilisateur.
|
||||||
|
///
|
||||||
|
/// Sémantique de merge — SCOPED aux blocs racine <c>system.generated.*</c> :
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>SEULS les blocs racine <c>system.generated.*</c> sont considérés.
|
||||||
|
/// Tous les autres blocs (<c>DesktopUI</c>, <c>GpuSpeed</c>, <c>LastKnown</c>,
|
||||||
|
/// <c>dashboard</c>, <c>steamvr</c>, <c>trackers</c>, …) sont user-/machine-
|
||||||
|
/// spécifiques et VARIENT légitimement entre postes — le launcher ne
|
||||||
|
/// les touche jamais (sinon faux positifs de diff à chaque install).</item>
|
||||||
|
/// <item>Bloc <c>system.generated.*</c> ABSENT côté cible → push complet du
|
||||||
|
/// bloc tel quel (les 4 leaf keys fournies par l'installer dans le ZIP).</item>
|
||||||
|
/// <item>Bloc PRÉSENT côté cible → réécriture des leaf keys
|
||||||
|
/// <c>*_CurrentURL_openxr</c> et <c>*_PreviousURL_openxr</c> uniquement.
|
||||||
|
/// Les autres leaf keys (<c>_AutosaveURL_openxr</c>,
|
||||||
|
/// <c>_NeedToUpdateAutosave_openxr</c>) restent intactes — gérées par
|
||||||
|
/// SteamVR.</item>
|
||||||
|
/// <item>La décision "merge nécessaire" repose UNIQUEMENT sur la diff des
|
||||||
|
/// CurrentURL/PreviousURL (ou l'absence du bloc). Pas de popup si l'install
|
||||||
|
/// est ré-effectué et que ces valeurs n'ont pas bougé.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// Tue préalablement SteamVR + Vive Business Streaming pour éviter qu'ils ne
|
||||||
|
/// rétablissent leurs valeurs en mémoire au prochain flush vers disque
|
||||||
|
/// (Vive Business Streaming en premier car il relance SteamVR sinon).
|
||||||
|
/// </summary>
|
||||||
|
public interface ISteamVrSettingsDeployer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Dry-run : détermine si un merge réel modifierait la cible. Permet à
|
||||||
|
/// MainViewModel de skipper la phase entière (popup + kill SteamVR/VBS +
|
||||||
|
/// merge) quand la cible est déjà à jour. <b>Aucun processus tué, aucun
|
||||||
|
/// fichier touché.</b> Bénéfice opérationnel : sur un PC où la conf SteamVR
|
||||||
|
/// est déjà conforme à la release courante, la ré-install ne dérange pas
|
||||||
|
/// l'opérateur ni les sessions VR éventuellement en cours.
|
||||||
|
/// </summary>
|
||||||
|
Task<SteamVrMergeCheckResult> CheckMergeNeededAsync(
|
||||||
|
string installFolder,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <param name="processesToKill">
|
||||||
|
/// Liste explicite des noms de processus à terminer avant le merge.
|
||||||
|
/// MainViewModel construit cette liste depuis les health checks de type
|
||||||
|
/// "Process" (= les processus que l'opérateur surveille déjà = ceux qui
|
||||||
|
/// risquent de garder un handle sur le fichier SteamVR ou de relancer
|
||||||
|
/// SteamVR). VBS doit y être en tête. Si null ou vide, on retombe sur
|
||||||
|
/// la liste par défaut de <see cref="SteamVrConfig.ProcessesToKill"/>.
|
||||||
|
/// </param>
|
||||||
|
Task<SteamVrMergeResult> MergeAsync(
|
||||||
|
string installFolder,
|
||||||
|
IReadOnlyList<string>? processesToKill,
|
||||||
|
CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Résultat du dry-run <see cref="ISteamVrSettingsDeployer.CheckMergeNeededAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum SteamVrMergeCheckOutcome
|
||||||
|
{
|
||||||
|
/// <summary>La cible contient déjà toutes les clés du source avec les
|
||||||
|
/// mêmes valeurs (deep equality) — aucun changement ne serait apporté.
|
||||||
|
/// MainViewModel doit skipper la phase sans déranger l'opérateur.</summary>
|
||||||
|
UpToDate,
|
||||||
|
/// <summary>Le merge apporterait au moins un changement (clé ajoutée ou
|
||||||
|
/// valeur remplacée) — MainViewModel doit proposer le pop-up et procéder.</summary>
|
||||||
|
Needed,
|
||||||
|
/// <summary>Pas de <c>_steamvr/steamvr.vrsettings</c> dans le ZIP — rien à faire.</summary>
|
||||||
|
NoSourceFile,
|
||||||
|
/// <summary>Option <c>AutoMerge=false</c> dans la config — l'opérateur l'a désactivé.</summary>
|
||||||
|
DisabledByConfig,
|
||||||
|
/// <summary>Steam pas détecté sur la machine.</summary>
|
||||||
|
SteamNotFound,
|
||||||
|
/// <summary>JSON source ou cible invalide. MainViewModel peut log warn.</summary>
|
||||||
|
InvalidJson,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="Outcome">État global du check.</param>
|
||||||
|
/// <param name="TargetPath">Chemin résolu vers <c>steamvr.vrsettings</c>. Non-null
|
||||||
|
/// si Steam a été localisé, même si le fichier n'existe pas encore.</param>
|
||||||
|
/// <param name="RootKeysWouldChange">Nombre de clés racine qui seraient ajoutées
|
||||||
|
/// ou modifiées. <c>0</c> ssi <see cref="Outcome"/> est <see cref="SteamVrMergeCheckOutcome.UpToDate"/>.
|
||||||
|
/// Affiché dans le popup pour donner du contexte à l'opérateur.</param>
|
||||||
|
/// <param name="ErrorMessage">Détail technique en cas d'<see cref="SteamVrMergeCheckOutcome.InvalidJson"/>.</param>
|
||||||
|
public sealed record SteamVrMergeCheckResult(
|
||||||
|
SteamVrMergeCheckOutcome Outcome,
|
||||||
|
string? TargetPath,
|
||||||
|
int RootKeysWouldChange,
|
||||||
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
|
public enum SteamVrMergeStatus
|
||||||
|
{
|
||||||
|
/// <summary>Pas de <c>_steamvr/steamvr.vrsettings</c> dans le ZIP, no-op silencieux.</summary>
|
||||||
|
SkippedNoSourceFile,
|
||||||
|
/// <summary>Option <c>SteamVrConfig.AutoMerge=false</c>, l'opérateur l'a explicitement désactivé.</summary>
|
||||||
|
SkippedDisabledByConfig,
|
||||||
|
/// <summary>Steam introuvable (ni via registre ni au path par défaut).</summary>
|
||||||
|
SteamNotFound,
|
||||||
|
/// <summary>Le fichier source ou cible n'est pas un JSON valide.</summary>
|
||||||
|
InvalidJson,
|
||||||
|
/// <summary>Tout s'est bien passé.</summary>
|
||||||
|
Merged,
|
||||||
|
/// <summary>Erreur I/O ou autre exception non récupérable.</summary>
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <param name="KeysReplaced">Nombre de clés racine qui existaient déjà côté cible (overwritées).</param>
|
||||||
|
/// <param name="KeysAdded">Nombre de clés racine ajoutées (n'existaient pas côté cible).</param>
|
||||||
|
/// <param name="TargetPath">Chemin du fichier modifié. Null si le merge a été skippé.</param>
|
||||||
|
/// <param name="ProcessesKilled">Nombre total de processus tués avant le merge.</param>
|
||||||
|
/// <param name="ErrorMessage">Détail technique en cas de Failed / InvalidJson.</param>
|
||||||
|
public sealed record SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus Status,
|
||||||
|
int KeysReplaced,
|
||||||
|
int KeysAdded,
|
||||||
|
string? TargetPath,
|
||||||
|
int ProcessesKilled,
|
||||||
|
string? ErrorMessage = null);
|
||||||
622
src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs
Normal file
622
src/PSLauncher.Core/SteamVr/SteamVrSettingsDeployer.cs
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
using SysProcess = System.Diagnostics.Process;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.SteamVr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implémentation du merge SteamVR. Voir <see cref="ISteamVrSettingsDeployer"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
|
||||||
|
{
|
||||||
|
/// <summary>Sous-dossier dans le ZIP PROSERVE où chercher le fichier source.</summary>
|
||||||
|
private const string SourceFolderName = "_steamvr";
|
||||||
|
/// <summary>Nom canonique du fichier de settings SteamVR (côté source ET cible).</summary>
|
||||||
|
private const string SettingsFileName = "steamvr.vrsettings";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pattern d'identification d'un bloc auto-généré par SteamVR pour une
|
||||||
|
/// application OpenXR donnée. Ex :
|
||||||
|
/// <c>system.generated.openxr.proserve_ue_5_5.proserve_ue_5_5.exe</c>.
|
||||||
|
///
|
||||||
|
/// Le launcher ne considère QUE ces blocs pour la diff + le push. Tous les
|
||||||
|
/// autres blocs racine du source (<c>DesktopUI</c>, <c>GpuSpeed</c>,
|
||||||
|
/// <c>LastKnown</c>, <c>dashboard</c>, <c>steamvr</c>, <c>trackers</c>, …)
|
||||||
|
/// sont user-/machine-spécifiques et VARIENT légitimement entre postes —
|
||||||
|
/// les checker provoquerait des faux positifs de diff à chaque install.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly Regex SystemGeneratedKey =
|
||||||
|
new(@"^system\.generated\.", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Leaf keys dans un bloc <c>system.generated.*</c> qui pilotent la diff.
|
||||||
|
/// Si l'une ou l'autre diffère entre source et target → le bloc est marqué
|
||||||
|
/// "à mettre à jour". Les autres leaf keys (<c>_AutosaveURL_openxr</c>,
|
||||||
|
/// <c>_NeedToUpdateAutosave_openxr</c>) ne sont PAS considérées pour la
|
||||||
|
/// décision de diff, mais elles sont quand même écrites lors du push initial
|
||||||
|
/// d'un bloc absent du target.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsRelevantOpenXrLeaf(string leafKey) =>
|
||||||
|
leafKey.EndsWith("_CurrentURL_openxr", StringComparison.Ordinal)
|
||||||
|
|| leafKey.EndsWith("_PreviousURL_openxr", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Délai d'attente entre le kill des processus et l'ouverture du fichier.
|
||||||
|
/// SteamVR garde un handle sur steamvr.vrsettings et le flush ses changements
|
||||||
|
/// au shutdown ⇒ on attend que le process se termine vraiment avant de toucher
|
||||||
|
/// au fichier. 1 s suffit en pratique mais 2 s pour avoir une marge.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan PostKillSettleDelay = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
private readonly Func<SteamVrConfig> _configProvider;
|
||||||
|
private readonly ILogger<SteamVrSettingsDeployer> _logger;
|
||||||
|
|
||||||
|
public SteamVrSettingsDeployer(
|
||||||
|
Func<SteamVrConfig> configProvider,
|
||||||
|
ILogger<SteamVrSettingsDeployer> logger)
|
||||||
|
{
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SteamVrMergeCheckResult> CheckMergeNeededAsync(
|
||||||
|
string installFolder,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var config = _configProvider();
|
||||||
|
|
||||||
|
// 1) Fichier source présent ?
|
||||||
|
var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName);
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.NoSourceFile, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Option activée ?
|
||||||
|
if (!config.AutoMerge)
|
||||||
|
{
|
||||||
|
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.DisabledByConfig, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Cible localisée ?
|
||||||
|
var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride)
|
||||||
|
? config.SettingsFilePathOverride!
|
||||||
|
: FindSteamVrSettingsPath();
|
||||||
|
if (targetPath is null)
|
||||||
|
{
|
||||||
|
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.SteamNotFound, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Parse source. Si invalide, le vrai merge échouera de toute façon.
|
||||||
|
JsonObject sourceObj;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sourceText = await File.ReadAllTextAsync(sourcePath, ct);
|
||||||
|
sourceObj = JsonNode.Parse(sourceText) as JsonObject
|
||||||
|
?? throw new InvalidDataException("source root is not a JSON object");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new SteamVrMergeCheckResult(
|
||||||
|
SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0,
|
||||||
|
$"Source JSON invalide : {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Si la cible n'existe pas, tout est à ajouter — merge nécessaire.
|
||||||
|
if (!File.Exists(targetPath))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SteamVR target file does not exist yet ({Path}) — merge needed (will create from scratch)",
|
||||||
|
targetPath);
|
||||||
|
return new SteamVrMergeCheckResult(
|
||||||
|
SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Parse cible. Si invalide, on signale mais le vrai merge serait
|
||||||
|
// contraint d'écrire (= overwrite) une cible illisible.
|
||||||
|
JsonObject targetObj;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targetText = await File.ReadAllTextAsync(targetPath, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(targetText))
|
||||||
|
{
|
||||||
|
// Fichier vide ⇒ merge ajouterait tout le source.
|
||||||
|
return new SteamVrMergeCheckResult(
|
||||||
|
SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count);
|
||||||
|
}
|
||||||
|
targetObj = JsonNode.Parse(targetText) as JsonObject
|
||||||
|
?? throw new InvalidDataException("target root is not a JSON object");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new SteamVrMergeCheckResult(
|
||||||
|
SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0,
|
||||||
|
$"Cible JSON invalide : {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7) Diff scoped : SEULS les blocs racine `system.generated.*` sont
|
||||||
|
// considérés. Les autres (DesktopUI, GpuSpeed, LastKnown, dashboard,
|
||||||
|
// steamvr, trackers, …) sont user-/machine-spécifiques et varient
|
||||||
|
// légitimement entre postes — les checker générerait des faux positifs
|
||||||
|
// de diff à chaque install. Cf. SystemGeneratedKey.
|
||||||
|
//
|
||||||
|
// Pour chaque bloc system.generated.* :
|
||||||
|
// • ABSENT côté target → on push le bloc entier au merge (= compte
|
||||||
|
// comme un "change")
|
||||||
|
// • PRÉSENT côté target → on compare uniquement les leaf keys
|
||||||
|
// `*_CurrentURL_openxr` et `*_PreviousURL_openxr`. Si l'une diffère,
|
||||||
|
// c'est un "change" (l'opérateur a édité ses bindings). Sinon, skip.
|
||||||
|
int rootKeysChanging = 0;
|
||||||
|
foreach (var (key, srcValue) in sourceObj)
|
||||||
|
{
|
||||||
|
// Whitelist : on n'examine QUE les blocs system.generated.*
|
||||||
|
if (!SystemGeneratedKey.IsMatch(key)) continue;
|
||||||
|
|
||||||
|
if (!targetObj.ContainsKey(key))
|
||||||
|
{
|
||||||
|
rootKeysChanging++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var tgtValue = targetObj[key];
|
||||||
|
|
||||||
|
if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
|
||||||
|
{
|
||||||
|
if (OpenXrBindingUrlsDiffer(tgtChild, srcChild))
|
||||||
|
rootKeysChanging++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var outcome = rootKeysChanging > 0
|
||||||
|
? SteamVrMergeCheckOutcome.Needed
|
||||||
|
: SteamVrMergeCheckOutcome.UpToDate;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SteamVR merge check: outcome={Outcome}, rootKeysChanging={Count}, target={Path}",
|
||||||
|
outcome, rootKeysChanging, targetPath);
|
||||||
|
return new SteamVrMergeCheckResult(outcome, targetPath, rootKeysChanging);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SteamVrMergeResult> MergeAsync(
|
||||||
|
string installFolder,
|
||||||
|
IReadOnlyList<string>? processesToKill,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var config = _configProvider();
|
||||||
|
|
||||||
|
// 1. Cherche le fichier source dans le ZIP extrait. Pas de fichier ⇒
|
||||||
|
// pas de merge ⇒ silent skip (toutes les versions PROSERVE ne shippent
|
||||||
|
// pas forcément un _steamvr/).
|
||||||
|
var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName);
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No _steamvr/{File} in {Folder}, skipping SteamVR merge",
|
||||||
|
SettingsFileName, installFolder);
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.SkippedNoSourceFile, 0, 0, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Opt-out par config (peut être désactivé sur des machines de dev).
|
||||||
|
if (!config.AutoMerge)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("SteamVR merge disabled by config (AutoMerge=false)");
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.SkippedDisabledByConfig, 0, 0, null, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Résout le path du fichier cible (override config OU registre OU fallback).
|
||||||
|
var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride)
|
||||||
|
? config.SettingsFilePathOverride!
|
||||||
|
: FindSteamVrSettingsPath();
|
||||||
|
if (targetPath is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Steam install not found via registry + fallback, cannot merge SteamVR settings");
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.SteamNotFound, 0, 0, null, 0,
|
||||||
|
"Steam introuvable (registre HKCU\\Software\\Valve\\Steam + HKLM\\Wow6432Node + %ProgramFiles(x86)%\\Steam)");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 4. Tue SteamVR + Vive Business Streaming. Vive en PREMIER car il
|
||||||
|
// relance auto SteamVR sinon. On parse + write avec un délai de
|
||||||
|
// règlement APRES, parce que les processus flushent leurs settings
|
||||||
|
// au shutdown.
|
||||||
|
//
|
||||||
|
// Le caller (MainViewModel) construit la liste depuis les health
|
||||||
|
// checks de type Process (= les programmes que l'opérateur surveille
|
||||||
|
// déjà). Si rien n'est passé, on retombe sur la liste par défaut
|
||||||
|
// de la config — utile pour les setups sans health checks configurés.
|
||||||
|
var killList = (processesToKill is not null && processesToKill.Count > 0)
|
||||||
|
? processesToKill
|
||||||
|
: config.ProcessesToKill;
|
||||||
|
var killed = await KillProcessesAsync(killList, ct);
|
||||||
|
if (killed > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Killed {Count} SteamVR/VBS process(es), waiting {Delay}ms for file release",
|
||||||
|
killed, (int)PostKillSettleDelay.TotalMilliseconds);
|
||||||
|
await Task.Delay(PostKillSettleDelay, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Parse le JSON source. Si le fichier est invalide, on log et on
|
||||||
|
// abandonne — pas la peine de risquer un overwrite avec une cible
|
||||||
|
// potentiellement valide.
|
||||||
|
JsonObject sourceObj;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sourceText = await File.ReadAllTextAsync(sourcePath, ct);
|
||||||
|
sourceObj = JsonNode.Parse(sourceText) as JsonObject
|
||||||
|
?? throw new InvalidDataException("source root is not a JSON object");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Source SteamVR settings file is not valid JSON: {Path}", sourcePath);
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed,
|
||||||
|
$"Source JSON invalide : {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Parse le JSON cible (si absent ou vide → on part d'un objet
|
||||||
|
// vide, on créera le fichier from scratch). Si le fichier existe
|
||||||
|
// mais est invalide, on abandonne (mieux vaut un fichier intact
|
||||||
|
// que de l'écraser).
|
||||||
|
JsonObject targetObj;
|
||||||
|
if (File.Exists(targetPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targetText = await File.ReadAllTextAsync(targetPath, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(targetText))
|
||||||
|
{
|
||||||
|
targetObj = new JsonObject();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
targetObj = JsonNode.Parse(targetText) as JsonObject
|
||||||
|
?? throw new InvalidDataException("target root is not a JSON object");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Target SteamVR settings file is not valid JSON: {Path}", targetPath);
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed,
|
||||||
|
$"Cible JSON invalide : {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Target SteamVR settings file does not exist yet, creating: {Path}",
|
||||||
|
targetPath);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||||
|
targetObj = new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Deep merge récursif :
|
||||||
|
// • Clé absente côté cible → ajoute (copie complète du source)
|
||||||
|
// • Clé présente des DEUX côtés ET les deux valeurs sont des objets
|
||||||
|
// JSON → recurse (préserve les sous-clés cible non touchées par le source)
|
||||||
|
// • Sinon (types différents, ou tableau, ou primitive) → le source gagne
|
||||||
|
// (full replace de la valeur)
|
||||||
|
//
|
||||||
|
// Cas concret du bloc "trackers" : si le fichier user contient déjà
|
||||||
|
// "trackers" : { "/devices/oculus/.../X" : "TrackerRole_...",
|
||||||
|
// "/devices/htc/TKR_LEFT" : "ancienne valeur" }
|
||||||
|
// et que le source apporte
|
||||||
|
// "trackers" : { "/devices/htc_business_streaming/TKR_LEFT" : "...",
|
||||||
|
// "/devices/htc_business_streaming/TKR_RIGHT" : "..." }
|
||||||
|
// → résultat fusionné = les 3 clés oculus + 2 htc_business_streaming
|
||||||
|
// (oculus préservée, ancienne htc/TKR_LEFT préservée puisque clé
|
||||||
|
// différente, htc_business_streaming/* ajoutées).
|
||||||
|
//
|
||||||
|
// Les compteurs top-level reflètent uniquement les clés RACINE
|
||||||
|
// (remplacées ou ajoutées) parce que c'est le niveau visible dans
|
||||||
|
// les logs et le seul niveau que l'opérateur a besoin de monitorer.
|
||||||
|
// Merge scoped : on n'agit QUE sur les blocs racine system.generated.*
|
||||||
|
// (cf. justification dans CheckMergeNeededAsync).
|
||||||
|
// • ABSENT côté target → push complet du bloc tel quel
|
||||||
|
// (= ce que l'installer fournit, avec ses 4 leaf keys)
|
||||||
|
// • PRÉSENT côté target → écrase les leaf keys CurrentURL +
|
||||||
|
// PreviousURL avec les valeurs du source. Les autres leaf keys
|
||||||
|
// (AutosaveURL, NeedToUpdate) restent comme côté target — gérées
|
||||||
|
// par SteamVR, pas touchées par le launcher.
|
||||||
|
int replaced = 0, added = 0;
|
||||||
|
foreach (var (key, srcValue) in sourceObj.ToList())
|
||||||
|
{
|
||||||
|
if (!SystemGeneratedKey.IsMatch(key)) continue;
|
||||||
|
|
||||||
|
if (!targetObj.ContainsKey(key))
|
||||||
|
{
|
||||||
|
// Bloc absent côté target → push complet du source
|
||||||
|
// (les 4 leaf keys, telles que fournies par l'installer).
|
||||||
|
targetObj[key] = srcValue?.DeepClone();
|
||||||
|
added++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (srcValue is JsonObject srcChild
|
||||||
|
&& targetObj[key] is JsonObject tgtChild)
|
||||||
|
{
|
||||||
|
// Bloc présent → on n'écrit QUE les leaf keys CurrentURL +
|
||||||
|
// PreviousURL depuis source (replace). Si elles matchent déjà,
|
||||||
|
// c'est un no-op au final (mais on tag quand même replaced
|
||||||
|
// car le check a déclenché un merge).
|
||||||
|
int leafTouched = ReplaceOpenXrBindingUrls(tgtChild, srcChild);
|
||||||
|
if (leafTouched > 0) replaced++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Write atomique via .tmp + rename. SteamVR utilise un JSON
|
||||||
|
// indenté à 3 espaces dans ses propres écritures — on respecte le
|
||||||
|
// style pour minimiser le diff visuel pour un opérateur qui ouvre
|
||||||
|
// le fichier en éditeur.
|
||||||
|
var json = targetObj.ToJsonString(new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
});
|
||||||
|
var tmpPath = targetPath + ".pslauncher-tmp";
|
||||||
|
await File.WriteAllTextAsync(tmpPath, json, ct);
|
||||||
|
// File.Move(overwrite=true) est atomique sur NTFS (.NET 5+)
|
||||||
|
File.Move(tmpPath, targetPath, overwrite: true);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SteamVR settings merge OK: replaced={Replaced}, added={Added}, target={Path}",
|
||||||
|
replaced, added, targetPath);
|
||||||
|
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.Merged, replaced, added, targetPath, killed);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "SteamVR merge failed unexpectedly");
|
||||||
|
return new SteamVrMergeResult(
|
||||||
|
SteamVrMergeStatus.Failed, 0, 0, targetPath, 0,
|
||||||
|
ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path canonique de Steam x86 quand il est installé à l'emplacement
|
||||||
|
/// standard (90 % des cas). Exposé pour que la UI Settings puisse afficher
|
||||||
|
/// la valeur attendue en placeholder du TextBox d'override.
|
||||||
|
/// </summary>
|
||||||
|
public static string CanonicalSettingsPath { get; } = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
|
||||||
|
"Steam", "config", "steamvr.vrsettings");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Résout le path vers <c>steamvr.vrsettings</c>. Stratégie : on essaie les
|
||||||
|
/// 3 sources dans l'ordre, et on retourne la PREMIÈRE qui pointe vers un
|
||||||
|
/// dossier Steam qui existe vraiment sur disque. Le fichier lui-même peut
|
||||||
|
/// ne pas exister encore — on le créera au write.
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>HKCU\Software\Valve\Steam → SteamPath (valeur typique : <c>C:/Program Files (x86)/Steam</c>)</item>
|
||||||
|
/// <item>HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath (typique : <c>C:\Program Files (x86)\Steam</c>)</item>
|
||||||
|
/// <item>Fallback hardcodé : <c>%ProgramFiles(x86)%\Steam\config\steamvr.vrsettings</c></item>
|
||||||
|
/// </list>
|
||||||
|
/// Retourne null si AUCUNE des 3 sources ne pointe vers un dossier Steam
|
||||||
|
/// existant — Steam n'est probablement pas installé sur cette machine.
|
||||||
|
/// </summary>
|
||||||
|
private string? FindSteamVrSettingsPath()
|
||||||
|
{
|
||||||
|
// Énumère les candidats dans l'ordre de priorité. Pour chacun on
|
||||||
|
// vérifie que la racine Steam (= grand-parent du fichier final) existe
|
||||||
|
// physiquement avant d'accepter — sinon un opérateur qui a désinstallé
|
||||||
|
// Steam puis ré-installé ailleurs avait des fantômes du registre qui
|
||||||
|
// nous renvoyaient vers un dossier inexistant.
|
||||||
|
var candidates = new (string Path, string Source)[]
|
||||||
|
{
|
||||||
|
(TryRegistryPath(Registry.CurrentUser, @"Software\Valve\Steam", "SteamPath"),
|
||||||
|
"HKCU\\Software\\Valve\\Steam"),
|
||||||
|
(TryRegistryPath(Registry.LocalMachine, @"SOFTWARE\WOW6432Node\Valve\Steam", "InstallPath"),
|
||||||
|
"HKLM\\Wow6432Node\\Valve\\Steam"),
|
||||||
|
(CanonicalSettingsPath, "fallback %ProgramFiles(x86)%\\Steam"),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var (candidate, source) in candidates)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(candidate)) continue;
|
||||||
|
// grand-parent = .../Steam (parent = .../Steam/config)
|
||||||
|
var steamRoot = Path.GetDirectoryName(Path.GetDirectoryName(candidate));
|
||||||
|
if (steamRoot is null || !Directory.Exists(steamRoot))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping SteamVR candidate from {Source} (Steam root {Root} doesn't exist)",
|
||||||
|
source, steamRoot);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Resolved SteamVR settings target via {Source}: {Path} (file {Exists})",
|
||||||
|
source, candidate, File.Exists(candidate) ? "exists" : "will be created");
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lit une string sous une clé registre + sous-clé nommée, normalise les
|
||||||
|
/// slashes Unix → Windows, puis compose le path vers <c>config\{SettingsFileName}</c>.
|
||||||
|
/// Retourne string.Empty si la clé n'existe pas ou si la valeur est vide.
|
||||||
|
/// Wrappé en try/catch silencieux : un manque de droits sur HKLM n'est pas
|
||||||
|
/// fatal — on tombera sur le candidat suivant.
|
||||||
|
/// </summary>
|
||||||
|
private string TryRegistryPath(RegistryKey root, string subKey, string valueName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = root.OpenSubKey(subKey);
|
||||||
|
if (key?.GetValue(valueName) is string raw && !string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
var normalized = raw.Replace('/', '\\').TrimEnd('\\');
|
||||||
|
return Path.Combine(normalized, "config", SettingsFileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Registry read failed for {Root}\\{SubKey}!{Value}",
|
||||||
|
root.Name, subKey, valueName);
|
||||||
|
}
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tue tous les processus dont le nom (sans .exe) correspond à un des
|
||||||
|
/// patterns de <paramref name="processNames"/> (case-insensitive). Best-effort :
|
||||||
|
/// si on ne peut pas killer un process (AccessDenied, déjà parti), on log
|
||||||
|
/// et on continue. Retourne le nombre total de process effectivement tués.
|
||||||
|
///
|
||||||
|
/// L'ordre des entrées dans la liste compte : Vive Business Streaming
|
||||||
|
/// doit être en TÊTE car il relance SteamVR sinon.
|
||||||
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Comparaison d'égalité structurelle entre deux JsonNode (objects, arrays,
|
||||||
|
/// primitives). System.Text.Json n'expose pas <c>DeepEquals</c> avant .NET 9 ;
|
||||||
|
/// on l'implémente à la main. Pour les primitives, on compare via la
|
||||||
|
/// serialization JSON par défaut — ToJsonString produit un format canonique
|
||||||
|
/// pour chaque type (number, string, bool, null), donc deux valeurs
|
||||||
|
/// logiquement égales produiront la même chaîne.
|
||||||
|
/// </summary>
|
||||||
|
private static bool JsonNodesEqual(JsonNode? a, JsonNode? b)
|
||||||
|
{
|
||||||
|
if (a is null && b is null) return true;
|
||||||
|
if (a is null || b is null) return false;
|
||||||
|
|
||||||
|
if (a is JsonObject aObj && b is JsonObject bObj)
|
||||||
|
{
|
||||||
|
if (aObj.Count != bObj.Count) return false;
|
||||||
|
foreach (var (key, aVal) in aObj)
|
||||||
|
{
|
||||||
|
if (!bObj.TryGetPropertyValue(key, out var bVal)) return false;
|
||||||
|
if (!JsonNodesEqual(aVal, bVal)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is JsonArray aArr && b is JsonArray bArr)
|
||||||
|
{
|
||||||
|
if (aArr.Count != bArr.Count) return false;
|
||||||
|
for (int i = 0; i < aArr.Count; i++)
|
||||||
|
{
|
||||||
|
if (!JsonNodesEqual(aArr[i], bArr[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a is JsonValue && b is JsonValue)
|
||||||
|
{
|
||||||
|
// Primitives : la sérialisation canonique gère uniformément les
|
||||||
|
// numbers (int/long/double), strings, bools, null.
|
||||||
|
return string.Equals(a.ToJsonString(), b.ToJsonString(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types incompatibles (ex: object vs primitive)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dry-run pour un bloc <c>system.generated.*</c> déjà présent côté target.
|
||||||
|
/// Retourne true si une des leaf keys pertinentes (<c>_CurrentURL_openxr</c>
|
||||||
|
/// ou <c>_PreviousURL_openxr</c>) DIFFÈRE entre source et target. Les autres
|
||||||
|
/// leaf keys (AutosaveURL, NeedToUpdate, …) sont ignorées pour la décision.
|
||||||
|
/// </summary>
|
||||||
|
private static bool OpenXrBindingUrlsDiffer(JsonObject target, JsonObject source)
|
||||||
|
{
|
||||||
|
foreach (var (leafKey, srcValue) in source)
|
||||||
|
{
|
||||||
|
if (!IsRelevantOpenXrLeaf(leafKey)) continue;
|
||||||
|
if (!target.TryGetPropertyValue(leafKey, out var tgtValue)) return true; // absent du target
|
||||||
|
if (!JsonNodesEqual(tgtValue, srcValue)) return true; // valeur différente
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merge effectif sur un bloc <c>system.generated.*</c> déjà présent côté
|
||||||
|
/// target. Réécrit les leaf keys CurrentURL et PreviousURL depuis le source,
|
||||||
|
/// laisse les autres leaf keys (<c>_AutosaveURL_openxr</c>,
|
||||||
|
/// <c>_NeedToUpdateAutosave_openxr</c>) intactes — c'est SteamVR qui les
|
||||||
|
/// gère, le launcher ne s'en mêle pas.
|
||||||
|
/// Retourne le nombre de leaf keys effectivement touchées (= 0 si source et
|
||||||
|
/// target ont déjà les mêmes URLs, sinon > 0).
|
||||||
|
/// </summary>
|
||||||
|
private static int ReplaceOpenXrBindingUrls(JsonObject target, JsonObject source)
|
||||||
|
{
|
||||||
|
int touched = 0;
|
||||||
|
foreach (var (leafKey, leafValue) in source.ToList())
|
||||||
|
{
|
||||||
|
if (!IsRelevantOpenXrLeaf(leafKey)) continue;
|
||||||
|
// Skip si target a déjà la même valeur exacte (no-op silencieux,
|
||||||
|
// pas la peine de marquer touched).
|
||||||
|
if (target.TryGetPropertyValue(leafKey, out var existing)
|
||||||
|
&& JsonNodesEqual(existing, leafValue))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
target[leafKey] = leafValue?.DeepClone();
|
||||||
|
touched++;
|
||||||
|
}
|
||||||
|
return touched;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> KillProcessesAsync(
|
||||||
|
IReadOnlyList<string> processNames,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
int total = 0;
|
||||||
|
foreach (var name in processNames)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var trimmed = name?.Trim();
|
||||||
|
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||||
|
// GetProcessesByName attend le nom SANS le .exe ; un opérateur peut
|
||||||
|
// l'avoir saisi avec → strip défensif.
|
||||||
|
if (trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
trimmed = trimmed[..^4];
|
||||||
|
|
||||||
|
SysProcess[] procs;
|
||||||
|
try { procs = SysProcess.GetProcessesByName(trimmed); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "GetProcessesByName({Name}) failed", trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var p in procs)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (p.HasExited) { p.Dispose(); continue; }
|
||||||
|
_logger.LogInformation("Killing process {Name} (PID {Pid})", p.ProcessName, p.Id);
|
||||||
|
p.Kill(entireProcessTree: true);
|
||||||
|
// Wait un peu pour que le process release ses handles
|
||||||
|
try { await p.WaitForExitAsync(ct); }
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch { /* race avec Kill possible, ok */ }
|
||||||
|
total++;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Could not kill process {Name} (PID {Pid}) — continuing",
|
||||||
|
p.ProcessName, p.Id);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { p.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,14 @@ namespace PSLauncher.Core.Updates;
|
|||||||
|
|
||||||
public interface IUpdateChecker
|
public interface IUpdateChecker
|
||||||
{
|
{
|
||||||
Task<UpdateCheckResult> CheckAsync(CancellationToken ct);
|
/// <param name="canSeeBetas">
|
||||||
|
/// Si false, les versions marquées <see cref="VersionManifest.IsBeta"/> sont
|
||||||
|
/// exclues de la sélection du <c>LatestRemote</c> — pas de popup « MAJ dispo »
|
||||||
|
/// pour une beta que le client ne pourrait de toute façon pas voir dans la
|
||||||
|
/// liste après filtrage license côté <c>MainViewModel.RebuildList</c>. Le
|
||||||
|
/// manifest complet est quand même retourné pour le rendu ultérieur.
|
||||||
|
/// </param>
|
||||||
|
Task<UpdateCheckResult> CheckAsync(bool canSeeBetas, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UpdateCheckResult(
|
public sealed record UpdateCheckResult(
|
||||||
|
|||||||
@@ -21,29 +21,56 @@ public sealed class UpdateChecker : IUpdateChecker
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UpdateCheckResult> CheckAsync(CancellationToken ct)
|
public async Task<UpdateCheckResult> CheckAsync(bool canSeeBetas, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var manifest = await _manifestService.FetchAsync(ct).ConfigureAwait(false);
|
var manifest = await _manifestService.FetchAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
// On ignore le champ `manifest.Latest` (trop facile à oublier au serveur).
|
// On ignore le champ `manifest.Latest` (trop facile à oublier au serveur).
|
||||||
// On prend toujours la plus haute version SemVer disponible et téléchargeable.
|
// On prend toujours la plus haute version disponible et téléchargeable, en
|
||||||
var latest = manifest.Versions
|
// ordonnant via VersionOrder — ça respecte la règle « non-beta > beta au
|
||||||
.Where(v => v.AvailableForDownload)
|
// même préfixe 3-digit » (ex : 1.5.4 non-beta > 1.5.4.32 beta), sinon
|
||||||
.OrderByDescending(v => SemVer.Parse(v.Version))
|
// c'est du SemVer strict.
|
||||||
|
//
|
||||||
|
// Filtre isBeta : les clients sans droits beta ne doivent JAMAIS voir une
|
||||||
|
// beta comme LatestRemote — sinon le popup « MAJ dispo » leur propose une
|
||||||
|
// version qu'ils ne pourront pas installer (filtrée en aval par RebuildList).
|
||||||
|
// Le filtre est appliqué avant le tri VersionOrder pour éviter qu'une beta
|
||||||
|
// ne se glisse comme "latest" chez un client non-autorisé, même si elle
|
||||||
|
// aurait été détrônée par une non-beta au même préfixe 3-digit.
|
||||||
|
var candidates = manifest.Versions.Where(v => v.AvailableForDownload);
|
||||||
|
if (!canSeeBetas) candidates = candidates.Where(v => !v.IsBeta);
|
||||||
|
var latest = candidates
|
||||||
|
.OrderByDescending(v => v, Comparer<VersionManifest>.Create(VersionOrder.Compare))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
var installed = _registry.Scan();
|
var installed = _registry.Scan();
|
||||||
|
// Pour les installs locales on ne connaît pas leur statut beta d'origine
|
||||||
|
// (l'entrée manifest a pu disparaître entre-temps). On les compare en
|
||||||
|
// SemVer pur — c'est acceptable parce que le vrai check « faut-il updater »
|
||||||
|
// ci-dessous confronte l'install LA PLUS HAUTE au meilleur remote, qui
|
||||||
|
// lui embarque son isBeta.
|
||||||
var latestInstalled = installed
|
var latestInstalled = installed
|
||||||
.OrderByDescending(v => SemVer.Parse(v.Version))
|
.OrderByDescending(v => SemVer.Parse(v.Version))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
var isLatestInstalled = latest is not null && latestInstalled is not null
|
var isLatestInstalled = latest is not null && latestInstalled is not null
|
||||||
&& latestInstalled.Version == latest.Version;
|
&& latestInstalled.Version == latest.Version;
|
||||||
|
// Pour « isNewer », on doit décider si `latest` (remote) supersede
|
||||||
|
// `latestInstalled` (local). On cherche l'entrée manifest correspondante
|
||||||
|
// à l'install locale pour retrouver son isBeta d'origine. Si absente
|
||||||
|
// (typiquement : install locale d'une ancienne beta que l'opérateur a
|
||||||
|
// supprimée du manifest), on la traite comme non-beta pour ne pas
|
||||||
|
// fausser la comparaison en défaveur de l'install locale.
|
||||||
|
var installedRemote = latestInstalled is null
|
||||||
|
? null
|
||||||
|
: manifest.Versions.FirstOrDefault(v => v.Version == latestInstalled.Version);
|
||||||
|
var installedIsBeta = installedRemote?.IsBeta ?? false;
|
||||||
var isNewer = latest is not null
|
var isNewer = latest is not null
|
||||||
&& (latestInstalled is null
|
&& (latestInstalled is null
|
||||||
|| SemVer.Parse(latest.Version).CompareTo(SemVer.Parse(latestInstalled.Version)) > 0);
|
|| VersionOrder.Compare(latest.Version, latest.IsBeta,
|
||||||
|
latestInstalled.Version, installedIsBeta) > 0);
|
||||||
|
|
||||||
return new UpdateCheckResult(manifest, latest, latestInstalled, isLatestInstalled, isNewer, null);
|
return new UpdateCheckResult(manifest, latest, latestInstalled, isLatestInstalled, isNewer, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
namespace PSLauncher.Models;
|
namespace PSLauncher.Models;
|
||||||
|
|
||||||
|
/// <param name="EntryId">
|
||||||
|
/// Id de l'entrée manifest source, lu depuis <c>.proserve-meta.json</c>. Null
|
||||||
|
/// pour les installs antérieurs à v1.0.8 (rétro-compat). Sert au matching
|
||||||
|
/// installed ↔ remote quand deux entries partagent un folder name résolu.
|
||||||
|
/// </param>
|
||||||
public sealed record InstalledVersion(
|
public sealed record InstalledVersion(
|
||||||
string Version,
|
string Version,
|
||||||
string FolderPath,
|
string FolderPath,
|
||||||
string ExecutablePath,
|
string ExecutablePath,
|
||||||
DateTime InstalledAt,
|
DateTime InstalledAt,
|
||||||
long SizeBytes);
|
long SizeBytes,
|
||||||
|
string? EntryId = null);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ public sealed class LocalConfig
|
|||||||
public string InstallRoot { get; set; } = string.Empty;
|
public string InstallRoot { get; set; } = string.Empty;
|
||||||
public string? LastLaunchedVersion { get; set; }
|
public string? LastLaunchedVersion { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Arguments CLI appliqués à CHAQUE lancement de PROSERVE (mode auto ou manuel).
|
||||||
|
/// Format identique à <see cref="AutoModeConfig.Args"/> : flag <c>-{Key}</c> si
|
||||||
|
/// <c>Value</c> null/vide, sinon <c>-{Key}={Value}</c>. Concaténés en premier ;
|
||||||
|
/// si la version lancée est aussi la version auto, les <see cref="AutoModeConfig.Args"/>
|
||||||
|
/// s'ajoutent DERRIÈRE (Unreal <c>FParse</c> lit dans l'ordre — la dernière occurrence
|
||||||
|
/// gagne sur un même key). Éditable dans Settings → « Arguments de lancement ».
|
||||||
|
/// Exemples typiques : <c>-nosplash</c>, <c>-log</c>, <c>-fps=90</c>.
|
||||||
|
/// </summary>
|
||||||
|
public List<AutoModeArg> DefaultLaunchArgs { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Code de langue de l'UI : "auto" (suit la langue Windows), "fr", "en",
|
/// Code de langue de l'UI : "auto" (suit la langue Windows), "fr", "en",
|
||||||
/// "zh", "th" ou "ar". Tout changement nécessite un redémarrage du launcher.
|
/// "zh", "th" ou "ar". Tout changement nécessite un redémarrage du launcher.
|
||||||
@@ -71,6 +82,15 @@ public sealed class LocalConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ApiToolConfig ApiTool { get; set; } = new();
|
public ApiToolConfig ApiTool { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration du merge des settings SteamVR. Si le ZIP contient un sous-dossier
|
||||||
|
/// <c>_steamvr/</c> avec <c>steamvr.vrsettings</c>, le launcher arrête SteamVR
|
||||||
|
/// et Vive Business Streaming, fusionne les blocs racine du JSON source dans le
|
||||||
|
/// fichier SteamVR de l'utilisateur (replace si la clé existe, ajoute sinon),
|
||||||
|
/// puis laisse SteamVR redémarrer naturellement quand l'utilisateur lance PROSERVE.
|
||||||
|
/// </summary>
|
||||||
|
public SteamVrConfig SteamVr { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cache LAN P2P : permet à un PC du LAN de partager les ZIPs déjà téléchargés
|
/// Cache LAN P2P : permet à un PC du LAN de partager les ZIPs déjà téléchargés
|
||||||
/// aux autres PCs (mode serveur), et/ou de chercher les ZIPs sur le LAN avant
|
/// aux autres PCs (mode serveur), et/ou de chercher les ZIPs sur le LAN avant
|
||||||
@@ -80,6 +100,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 +335,121 @@ public sealed class HealthCheckEntry
|
|||||||
public int? PingTimeoutMs { get; set; } = 1000;
|
public int? PingTimeoutMs { get; set; } = 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration du merge SteamVR au moment de l'install d'une nouvelle version
|
||||||
|
/// PROSERVE. Le ZIP peut contenir un dossier <c>_steamvr/steamvr.vrsettings</c>
|
||||||
|
/// avec des blocs racine spécifiques au jeu (system.generated.openxr.*, trackers,
|
||||||
|
/// etc.). Si <see cref="AutoMerge"/> est true, le launcher :
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>tue SteamVR + Vive Business Streaming (qui le relance auto)</item>
|
||||||
|
/// <item>localise <c>steamvr.vrsettings</c> via le registre Steam (ou <see cref="SettingsFilePathOverride"/>)</item>
|
||||||
|
/// <item>parse les deux JSON, remplace chaque clé racine du source dans la cible (ou ajoute si absente)</item>
|
||||||
|
/// <item>écrit le résultat atomiquement (rename .tmp → final)</item>
|
||||||
|
/// </list>
|
||||||
|
/// SteamVR redémarrera naturellement quand l'opérateur relance PROSERVE après
|
||||||
|
/// l'install.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SteamVrConfig
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Si true, le launcher applique le merge automatiquement à chaque install
|
||||||
|
/// quand <c>_steamvr/steamvr.vrsettings</c> est présent dans le ZIP. Default
|
||||||
|
/// true (transparent pour l'opérateur), mais peut être désactivé pour les
|
||||||
|
/// machines de dev où le settings doit rester intact.
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoMerge { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path explicite vers le <c>steamvr.vrsettings</c> à modifier. Null →
|
||||||
|
/// auto-détection via :
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>HKCU\Software\Valve\Steam → SteamPath → config\steamvr.vrsettings</item>
|
||||||
|
/// <item>HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath → idem</item>
|
||||||
|
/// <item>fallback %ProgramFilesX86%\Steam\config\steamvr.vrsettings</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public string? SettingsFilePathOverride { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processus à tuer avant le merge (sans le suffixe .exe). Default = la
|
||||||
|
/// liste typique SteamVR + Vive Business Streaming. Vive Business Streaming
|
||||||
|
/// relance auto SteamVR ⇒ il faut le tuer EN PREMIER. Editable depuis
|
||||||
|
/// Settings pour les setups exotiques (autres runtimes XR).
|
||||||
|
/// </summary>
|
||||||
|
public List<string> ProcessesToKill { get; set; } = new()
|
||||||
|
{
|
||||||
|
// Vive Business Streaming (à tuer en premier — relance SteamVR sinon)
|
||||||
|
"VIVE Business Streaming",
|
||||||
|
"RRConsole",
|
||||||
|
"RRServer",
|
||||||
|
"HtcConnectionUtility",
|
||||||
|
// SteamVR
|
||||||
|
"vrserver",
|
||||||
|
"vrmonitor",
|
||||||
|
"vrcompositor",
|
||||||
|
"vrdashboard",
|
||||||
|
"vrwebhelper",
|
||||||
|
"vrstartup",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 +480,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
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ public sealed class LauncherInfo
|
|||||||
|
|
||||||
public sealed class VersionManifest
|
public sealed class VersionManifest
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identifiant unique de l'entrée manifest, généré par le backoffice
|
||||||
|
/// (<c>generate_entry_id()</c>). Optionnel côté client : les vieux
|
||||||
|
/// manifests sans <c>id</c> continuent de fonctionner (rétro-compat).
|
||||||
|
/// Utilisé principalement pour le row-key côté UI et pour l'install guard
|
||||||
|
/// anti-collision (deux entries partageant version + folder → refuse).
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string? Id { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("version")]
|
[JsonPropertyName("version")]
|
||||||
public string Version { get; set; } = string.Empty;
|
public string Version { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
55
version/proserve-ReleaseTemplate/_api/config/constants.php
Normal file
55
version/proserve-ReleaseTemplate/_api/config/constants.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
// define constants for table names
|
||||||
|
// "data"
|
||||||
|
define("USERS_TABLE_NAME", "users");
|
||||||
|
define("SESSIONS_TABLE_NAME", "sessions");
|
||||||
|
define("PARTICIPATES_TABLE_NAME", "participates");
|
||||||
|
define("TRIGGEREVENTS_TABLE_NAME", "triggerevents");
|
||||||
|
define("REACTEVENTS_TABLE_NAME", "reactevents");
|
||||||
|
// types
|
||||||
|
define("TRIGGEREVENTTYPES_TABLE_NAME", "triggereventtypes");
|
||||||
|
define("REACTEVENTTYPES_TABLE_NAME", "reacteventtypes");
|
||||||
|
define("REACTEVENTMODES_TABLE_NAME", "reacteventmodes");
|
||||||
|
define("SESSIONTYPES_TABLE_NAME", "sessiontypes");
|
||||||
|
define("USERSTATUS_TABLE_NAME", "userstatus");
|
||||||
|
define("USERROLES_TABLE_NAME", "userroles");
|
||||||
|
// views
|
||||||
|
define("SESSIONDEBRIEFS_VIEW_NAME", "sessiondebriefs");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//////////////////////////////////////// query strings for SESSIONDEBRIEFS_VIEW ////////////////////////////////////////
|
||||||
|
// SELECT clause
|
||||||
|
define("SESSIONDEBRIEFS_VIEW_QUERY_SELECT",
|
||||||
|
"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 clause
|
||||||
|
define ("SESSIONDEBRIEFS_VIEW_QUERY_FROM",
|
||||||
|
" FROM " . SESSIONS_TABLE_NAME . " S LEFT JOIN ". PARTICIPATES_TABLE_NAME . " PS ON (S.id = PS.sessionId) " .
|
||||||
|
"LEFT JOIN ". PARTICIPATES_TABLE_NAME . " PH ON (S.id = PH.sessionId) LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " T ON (S.id = T.sessionId) " .
|
||||||
|
"LEFT JOIN " . TRIGGEREVENTTYPES_TABLE_NAME . " TT ON (TT.id = T.type) LEFT JOIN " . SESSIONTYPES_TABLE_NAME . " ST ON (ST.id = S.sessionType) " .
|
||||||
|
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " R ON ( T.indexCount = R.srcEventIndex AND T.sessionId = R.srcEventSessionId ) " .
|
||||||
|
"LEFT JOIN " . USERS_TABLE_NAME . " UH ON (UH.id = R.hitUserId) LEFT JOIN " . USERS_TABLE_NAME . " US ON (US.id = T.srcUserId OR US.id = PS.userId ) " .
|
||||||
|
"LEFT JOIN " . REACTEVENTTYPES_TABLE_NAME . " RT ON (RT.id = R.reactType) LEFT JOIN " . REACTEVENTMODES_TABLE_NAME . " RM ON (RM.id = COALESCE(R.reactMode, 2)) " .
|
||||||
|
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " RK ON (R.id = RK.id AND RK.targetKilled = 1)");
|
||||||
|
|
||||||
|
// GROUP BY clause
|
||||||
|
define("SESSIONDEBRIEFS_VIEW_QUERY_GROUPBY", " GROUP BY SessionId,ShooterId,ShotIndex,TargetName");
|
||||||
|
|
||||||
|
// ORDER BY clause
|
||||||
|
define("SESSIONDEBRIEFS_VIEW_QUERY_ORDERBY", " ORDER BY SessionId,ShooterId,ShotIndex,ReactId");
|
||||||
|
|
||||||
|
// overall query string for SESSIONDEBRIEFS_VIEW (concatenation of SELECT, FROM, GROUP BY, ORDER BY clauses)
|
||||||
|
define("SESSIONDEBRIEFS_VIEW_QUERY", SESSIONDEBRIEFS_VIEW_QUERY_SELECT . SESSIONDEBRIEFS_VIEW_QUERY_FROM . SESSIONDEBRIEFS_VIEW_QUERY_GROUPBY . SESSIONDEBRIEFS_VIEW_QUERY_ORDERBY);
|
||||||
|
?>
|
||||||
28
version/proserve-ReleaseTemplate/_api/config/database.php
Normal file
28
version/proserve-ReleaseTemplate/_api/config/database.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
// specify your own database credentials
|
||||||
|
private $host = "localhost"; //Server
|
||||||
|
private $db_name = "ProserveAPI"; //Database Name
|
||||||
|
private $username = "root"; //UserName of Phpmyadmin
|
||||||
|
private $password = ""; //Password associated with username
|
||||||
|
public $conn;
|
||||||
|
|
||||||
|
// get the database connection
|
||||||
|
public function getConnection()
|
||||||
|
{
|
||||||
|
$this->conn = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
$this->conn = new PDO("mysql:host=" . $this->host . ";dbname=" . $this->db_name, $this->username, $this->password);
|
||||||
|
$this->conn->exec("set names utf8");
|
||||||
|
}
|
||||||
|
catch(PDOException $exception)
|
||||||
|
{
|
||||||
|
echo "Connection error: " . $exception->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->conn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
25
version/proserve-ReleaseTemplate/_api/config/error.php
Normal file
25
version/proserve-ReleaseTemplate/_api/config/error.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// set custom error handler
|
||||||
|
set_error_handler("json_error_handler");
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function missing_parameter_error ($param_name)
|
||||||
|
{
|
||||||
|
$error_message = "Missing_Parameter:" . $param_name;
|
||||||
|
return $error_message;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function json_error_handler ($err_level, $err_message)
|
||||||
|
{
|
||||||
|
$err_arr=array (
|
||||||
|
"status" => false,
|
||||||
|
"level" => $err_level,
|
||||||
|
"message" => $err_message
|
||||||
|
);
|
||||||
|
|
||||||
|
//if ($err_level != 2)
|
||||||
|
print_r(json_encode($err_arr));
|
||||||
|
}
|
||||||
|
?>
|
||||||
20
version/proserve-ReleaseTemplate/_api/config/init.php
Normal file
20
version/proserve-ReleaseTemplate/_api/config/init.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
// CORS headers for web interface
|
||||||
|
header("Access-Control-Allow-Origin: *");
|
||||||
|
header("Access-Control-Allow-Methods: POST, GET, OPTIONS");
|
||||||
|
header("Access-Control-Allow-Headers: Content-Type");
|
||||||
|
header("Content-Type: application/json; charset=UTF-8");
|
||||||
|
|
||||||
|
// Handle preflight OPTIONS request
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/database.php';
|
||||||
|
|
||||||
|
// get database connection
|
||||||
|
$database = new Database();
|
||||||
|
$db = $database->getConnection();
|
||||||
|
?>
|
||||||
25
version/proserve-ReleaseTemplate/_api/debug/testpage.php
Normal file
25
version/proserve-ReleaseTemplate/_api/debug/testpage.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/init.php'; // contains $db
|
||||||
|
include_once '../config/error.php';
|
||||||
|
include_once '../objects/db_participates.php';
|
||||||
|
|
||||||
|
// prepare user object
|
||||||
|
$participation = new UserInGameSession($db);
|
||||||
|
$participation->sessionId = 1458;
|
||||||
|
$participation->userId = 6;
|
||||||
|
$participation->endStatus = 0;
|
||||||
|
|
||||||
|
// user leaves session
|
||||||
|
if ($participation->userLeaves())
|
||||||
|
{
|
||||||
|
// update score and averages
|
||||||
|
if ($participation->updateUserScores())
|
||||||
|
{
|
||||||
|
$participation_arr = $participation->getResultArray(true, "User_Left_OK");
|
||||||
|
print_r(json_encode($participation_arr)); // OK
|
||||||
|
}
|
||||||
|
else trigger_error("Calculate_Score_Failed");
|
||||||
|
}
|
||||||
|
else trigger_error("Leave_Session_Failed");
|
||||||
|
?>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/init.php'; // contains $db
|
||||||
|
include_once '../config/error.php';
|
||||||
|
include_once '../objects/db_reactevent.php';
|
||||||
|
|
||||||
|
// prepare user object
|
||||||
|
$event = new ReactEvent($db);
|
||||||
|
|
||||||
|
// read mandatory $_POST properties
|
||||||
|
// ensure sessionId, trigger index and event type are passed in $_POST parameters
|
||||||
|
if (isset($_POST['srcEventIndex']) && $_POST['srcEventIndex'] >= 0)
|
||||||
|
$event->srcEventIndex = $_POST['srcEventIndex'];
|
||||||
|
else trigger_error( missing_parameter_error("srcEventIndex") );
|
||||||
|
|
||||||
|
if (isset($_POST['srcEventSessionId']) && $_POST['srcEventSessionId'] > 0)
|
||||||
|
$event->srcEventSessionId = $_POST['srcEventSessionId'];
|
||||||
|
else trigger_error( missing_parameter_error("srcEventSessionId") );
|
||||||
|
|
||||||
|
if (isset($_POST['type']))
|
||||||
|
$event->reactType = $_POST['type'];
|
||||||
|
else trigger_error( missing_parameter_error("type") );
|
||||||
|
|
||||||
|
|
||||||
|
// read other $_POST properties
|
||||||
|
$event->hitUserId = $_POST['hitUserId'];
|
||||||
|
$event->hitTargetName = $_POST['targetName'];
|
||||||
|
$event->hitBoneName = $_POST['boneName'];
|
||||||
|
$event->damage = $_POST['damage'];
|
||||||
|
$event->targetKilled = (strcasecmp( ($_POST['targetKilled'] ?? false), "true" ) == 0) ? 1 : 0;
|
||||||
|
$event->objectHitLocationX = $_POST['objectHitLocationX'];
|
||||||
|
$event->objectHitLocationY = $_POST['objectHitLocationY'];
|
||||||
|
$event->objectHitTagLocation = $_POST['objectHitTagLocation'];
|
||||||
|
$event->hitPrecision = $_POST['hitPrecision'];
|
||||||
|
$event->distance = $_POST['distance'];
|
||||||
|
$event->reactTime = $_POST['reactTime'];
|
||||||
|
$event->reactMode = $_POST['reactMode'];
|
||||||
|
$event->timeStamp = $_POST['timestamp'];
|
||||||
|
|
||||||
|
// create reactevent
|
||||||
|
if ($event->record())
|
||||||
|
{
|
||||||
|
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
|
||||||
|
print_r(json_encode($evt_arr)); // OK
|
||||||
|
}
|
||||||
|
else trigger_error("Error_Occured");
|
||||||
|
?>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/init.php'; // contains $db
|
||||||
|
include_once '../config/error.php';
|
||||||
|
include_once '../objects/db_triggerevent.php';
|
||||||
|
|
||||||
|
// prepare user object
|
||||||
|
$event = new TriggerEvent($db);
|
||||||
|
|
||||||
|
// read mandatory $_POST properties
|
||||||
|
// ensure sessionId, userId and trigger index are passed in $_POST parameters
|
||||||
|
if (isset($_POST['session']) && $_POST['session'] > 0)
|
||||||
|
$event->sessionId = $_POST['session'];
|
||||||
|
else trigger_error( missing_parameter_error("session") );
|
||||||
|
|
||||||
|
if (isset($_POST['user']) && $_POST['user'] >= 0)
|
||||||
|
$event->srcUserId = $_POST['user'];
|
||||||
|
else trigger_error( missing_parameter_error("user") );
|
||||||
|
|
||||||
|
if (isset($_POST['index']) && $_POST['index'] >= 0)
|
||||||
|
$event->indexCount = $_POST['index'];
|
||||||
|
else trigger_error( missing_parameter_error("index") );
|
||||||
|
|
||||||
|
// read other $_POST properties
|
||||||
|
$event->type = $_POST['type'];
|
||||||
|
$event->timeStamp = $_POST['timestamp'];
|
||||||
|
$event->successful = $_POST['success'];
|
||||||
|
|
||||||
|
// create trigger event
|
||||||
|
if ($event->record())
|
||||||
|
{
|
||||||
|
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
|
||||||
|
print_r(json_encode($evt_arr)); // OK
|
||||||
|
}
|
||||||
|
else trigger_error("Error_Occured");
|
||||||
|
?>
|
||||||
14
version/proserve-ReleaseTemplate/_api/lists/all_sessions.php
Normal file
14
version/proserve-ReleaseTemplate/_api/lists/all_sessions.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/init.php'; // contains $db
|
||||||
|
include_once '../config/error.php';
|
||||||
|
include_once '../objects/stats_object.php';
|
||||||
|
|
||||||
|
$typeId = isset($_POST['typeId']) ? $_POST['typeId'] : -1;
|
||||||
|
|
||||||
|
$stats = new StatsObject($db);
|
||||||
|
$stats->getSessionsForUser(-1, $typeId);
|
||||||
|
|
||||||
|
$stats_arr = $stats->getResultArray(true, "All_Sessions_List_OK");
|
||||||
|
print_r(json_encode($stats_arr));
|
||||||
|
?>
|
||||||
12
version/proserve-ReleaseTemplate/_api/lists/all_users.php
Normal file
12
version/proserve-ReleaseTemplate/_api/lists/all_users.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/init.php'; // contains $db
|
||||||
|
include_once '../config/error.php';
|
||||||
|
include_once '../objects/stats_object.php';
|
||||||
|
|
||||||
|
$stats = new StatsObject($db);
|
||||||
|
$stats->getUsersInSession(-1);
|
||||||
|
|
||||||
|
$stats_arr = $stats->getResultArray(true, "All_Users_List_OK");
|
||||||
|
print_r(json_encode($stats_arr));
|
||||||
|
?>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/init.php'; // contains $db
|
||||||
|
include_once '../config/error.php';
|
||||||
|
include_once '../objects/stats_object.php';
|
||||||
|
|
||||||
|
$userId = isset($_POST['userId']) ? $_POST['userId'] : -1;
|
||||||
|
$typeId = isset($_POST['typeId']) ? $_POST['typeId'] : -1;
|
||||||
|
|
||||||
|
$stats = new StatsObject($db);
|
||||||
|
$stats->getSessionsForUser($userId, $typeId);
|
||||||
|
|
||||||
|
$stats_arr = $stats->getResultArray(true, "User_Sessions_List_OK");
|
||||||
|
print_r(json_encode($stats_arr)); // OK
|
||||||
|
?>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/init.php'; // contains $db
|
||||||
|
include_once '../config/error.php';
|
||||||
|
include_once '../objects/stats_object.php';
|
||||||
|
|
||||||
|
$sessionId = isset($_POST['sessionId']) ? $_POST['sessionId'] : -1;
|
||||||
|
|
||||||
|
$stats = new StatsObject($db);
|
||||||
|
$stats->getUsersInSession($sessionId);
|
||||||
|
|
||||||
|
$stats_arr = $stats->getResultArray(true, "Session_Users_List_OK");
|
||||||
|
print_r(json_encode($stats_arr)); // OK
|
||||||
|
?>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/db_table_object.php';
|
||||||
|
|
||||||
|
class DBObjectType extends DBTableObject
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
//private $table_name = "sessioneventtypes";
|
||||||
|
|
||||||
|
protected $array_key = "type";
|
||||||
|
|
||||||
|
// object properties
|
||||||
|
public $id = -1;
|
||||||
|
public $displayName = "";
|
||||||
|
public $description = "";
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"id" => $this->id,
|
||||||
|
"displayName" => $this->displayName,
|
||||||
|
"description" => $this->description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function save ()
|
||||||
|
{
|
||||||
|
// query to insert record of new user signup
|
||||||
|
$query = "INSERT INTO " . $this->table_name . " SET displayName=:displayName, description=:description";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// sanitize
|
||||||
|
$this->displayName=htmlspecialchars(strip_tags($this->displayName));
|
||||||
|
$this->description=htmlspecialchars(strip_tags($this->description));
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":displayName", $this->displayName);
|
||||||
|
$stmt->bindParam(":description", $this->description);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
if($stmt->execute())
|
||||||
|
{
|
||||||
|
$this->id = $this->conn->lastInsertId();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
<?php
|
||||||
|
include_once "../objects/db_table_object.php";
|
||||||
|
include_once "../objects/db_participates_results.php";
|
||||||
|
include_once '../score/score_algo1.php'; // for score calculation
|
||||||
|
|
||||||
|
class UserInGameSession extends DBTableObject
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
protected $table_name = PARTICIPATES_TABLE_NAME;
|
||||||
|
protected $array_key = "participation";
|
||||||
|
|
||||||
|
// object properties
|
||||||
|
public int $userId = -1;
|
||||||
|
public int $sessionId = 0;
|
||||||
|
public float $score = 0;
|
||||||
|
public float $firePrecision = 0.0;
|
||||||
|
public float $reactionTime = 0.0;
|
||||||
|
public int $nbEnemyHit = 0;
|
||||||
|
public int $nbCivilsHit = 0;
|
||||||
|
public int $damageTaken = 0;
|
||||||
|
public $endStatus = 0;
|
||||||
|
public $avatar = "";
|
||||||
|
public $weapon = "";
|
||||||
|
public int $role = 0;
|
||||||
|
public $results = "";
|
||||||
|
//public $replayFileName = "";
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"userId" => (int)$this->userId,
|
||||||
|
"sessionId" => (int)$this->sessionId,
|
||||||
|
"score" => (float)$this->score ?? 0.0,
|
||||||
|
"firePrecision" => (float)$this->firePrecision ?? 0.0,
|
||||||
|
"reactionTime" => (float)$this->reactionTime ?? 0.0,
|
||||||
|
"nbEnemyHit" => (int)$this->nbEnemyHit ?? 0,
|
||||||
|
"nbCivilsHit" => (int)$this->nbCivilsHit ?? 0,
|
||||||
|
"damageTaken" => (int)$this->damageTaken ?? 0,
|
||||||
|
"endStatus" => (int)$this->endStatus ?? 0,
|
||||||
|
"avatar" => $this->avatar ?? "",
|
||||||
|
"weapon" => $this->weapon ?? "",
|
||||||
|
"roleId" => $this->role,
|
||||||
|
"resultsAsString" => $this->results
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function readRow (array $row)
|
||||||
|
{
|
||||||
|
$this->userId = (int)$row['userId'];
|
||||||
|
$this->sessionId = (int)$row['sessionId'];
|
||||||
|
$this->score = (int)$row['score'];
|
||||||
|
$this->firePrecision = (float)$row['firePrecision'];
|
||||||
|
$this->reactionTime = (float)$row['reactionTime'];
|
||||||
|
$this->nbEnemyHit = (int)$row['nbEnemyHit'];
|
||||||
|
$this->nbCivilsHit = (int)$row['nbCivilsHit'];
|
||||||
|
$this->damageTaken = (int)$row['damageTaken'];
|
||||||
|
$this->endStatus = (int)$row['endStatus'];
|
||||||
|
$this->avatar = $row['avatar'];
|
||||||
|
$this->weapon = $row['weapon'];
|
||||||
|
$this->role = $row['role'];
|
||||||
|
$this->results = $row['results'];
|
||||||
|
//$this->replayFileName = $row['replayFileName'];
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function sanitize ()
|
||||||
|
{
|
||||||
|
$this->userId=htmlspecialchars(strip_tags($this->userId));
|
||||||
|
$this->sessionId=htmlspecialchars(strip_tags($this->sessionId));
|
||||||
|
$this->avatar=htmlspecialchars(strip_tags($this->avatar));
|
||||||
|
$this->weapon=htmlspecialchars(strip_tags($this->weapon));
|
||||||
|
$this->role=htmlspecialchars(strip_tags($this->role));
|
||||||
|
$this->results=htmlspecialchars(strip_tags($this->results));
|
||||||
|
//$this->nbCivilsHit=htmlspecialchars(strip_tags($this->nbCivilsHit));
|
||||||
|
//$this->nbEnemyHit=htmlspecialchars(strip_tags($this->nbEnemyHit));
|
||||||
|
//$this->score=htmlspecialchars(strip_tags($this->score));
|
||||||
|
//$this->firePrecision=htmlspecialchars(strip_tags($this->firePrecision));
|
||||||
|
//$this->damageTaken=htmlspecialchars(strip_tags($this->damageTaken));
|
||||||
|
//$this->replayFileName=htmlspecialchars(strip_tags($this->replayFileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function load ()
|
||||||
|
{
|
||||||
|
// select all query with user inputed username and password
|
||||||
|
$query = "SELECT * FROM " . $this->table_name . " WHERE userId='" . $this->userId . "' AND sessionId='" . $this->sessionId . "'";
|
||||||
|
|
||||||
|
// prepare query statement
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
if($stmt->rowCount() > 0)
|
||||||
|
{
|
||||||
|
// get retrieved row
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
// retrieve session values
|
||||||
|
$this->readRow($row);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function registerUser ()
|
||||||
|
{
|
||||||
|
//$this->sessionId = $session;
|
||||||
|
//$this->userId = $user;
|
||||||
|
//$this->avatar = $avatar;
|
||||||
|
//$this->weapon = $weapon;
|
||||||
|
|
||||||
|
if ($this->alreadyRegistered())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// query to insert record of new user signup
|
||||||
|
$query = "INSERT INTO " . $this->table_name . " SET sessionId=:sessionId, userId=:userId, avatar=:avatar, weapon=:weapon, role=:role";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// sanitize
|
||||||
|
$this->sanitize();
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt->bindParam(":userId", $this->userId);
|
||||||
|
$stmt->bindParam(":avatar", $this->avatar);
|
||||||
|
$stmt->bindParam(":weapon", $this->weapon);
|
||||||
|
$stmt->bindParam(":role", $this->role);
|
||||||
|
//$stmt->bindParam(":nbCivilsHit", $this->nbCivilsHit);
|
||||||
|
//$stmt->bindParam(":nbEnemyHit", $this->nbEnemyHit);
|
||||||
|
//$stmt->bindParam(":score", $this->score);
|
||||||
|
//$stmt->bindParam(":firePrecision", $this->firePrecision);
|
||||||
|
//$stmt->bindParam(":damageTaken", $this->damageTaken);
|
||||||
|
//$stmt->bindParam(":replayFileName", $this->replayFileName);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function alreadyRegistered ()
|
||||||
|
{
|
||||||
|
$query = "SELECT * FROM " . $this->table_name . " WHERE userId=:userId AND sessionId=:sessionId";
|
||||||
|
|
||||||
|
// prepare query statement
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// sanitize
|
||||||
|
$this->userId=htmlspecialchars(strip_tags($this->userId));
|
||||||
|
$this->sessionId=htmlspecialchars(strip_tags($this->sessionId));
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt->bindParam(":userId", $this->userId);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function updateUserScores ()
|
||||||
|
{
|
||||||
|
//$this->sessionId = $sessionId;
|
||||||
|
//$this->userId = $userId;
|
||||||
|
|
||||||
|
// load current values
|
||||||
|
$this->load();
|
||||||
|
|
||||||
|
$this->score = 0;
|
||||||
|
|
||||||
|
$query = "SELECT sessionType FROM Sessions WHERE id='" . $this->sessionId . "'";
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
if($stmt->rowCount() > 0)
|
||||||
|
{
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($row['sessionType'] == 0) // FireRange
|
||||||
|
{
|
||||||
|
$query = "SELECT R.id, R.hitPrecision, R.objectHitLocationX, R.objectHitLocationY, R.distance, R.objectHitTagLocation " . "
|
||||||
|
FROM " . REACTEVENTS_TABLE_NAME . " R, " . TRIGGEREVENTS_TABLE_NAME . " T " . "
|
||||||
|
WHERE R.srcEventSessionId=T.sessionId AND R.srcEventIndex=T.indexCount AND T.srcUserId=:userId AND T.sessionId=:sessionId";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt->bindParam(":userId", $this->userId);
|
||||||
|
//$stmt->bindParam(":score", $this->score);
|
||||||
|
//$stmt->bindParam(":role", $role);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
$totalPrecision=0;
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
$totalPrecision += $row['hitPrecision'];
|
||||||
|
|
||||||
|
$this->score = calculate_firerange_v1($totalPrecision);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$query = "SELECT R.id, R.hitPrecision, R.distance, R.reactType, P.nbCivilsHit " . "
|
||||||
|
FROM " . REACTEVENTS_TABLE_NAME . " R, " . TRIGGEREVENTS_TABLE_NAME . " T, " . $this->table_name . " P " . "
|
||||||
|
WHERE R.srcEventIndex=T.indexCount AND R.srcEventSessionId=T.sessionId AND P.sessionId=R.srcEventSessionId AND P.userId=T.srcUserId " . "
|
||||||
|
AND R.srcEventSessionId=:sessionId AND T.srcUserId=:userId AND (R.reactType=0 OR R.reactType=4 OR R.reactType=5)";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt->bindParam(":userId", $this->userId);
|
||||||
|
//$stmt->bindParam(":score", $this->score);
|
||||||
|
//$stmt->bindParam(":role", $role);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
if ($stmt->rowCount() > 0)
|
||||||
|
{
|
||||||
|
$totalPrecision=0;
|
||||||
|
$nbCivilHits=0;
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
$nbCivilHits = $row['nbCivilsHit'];
|
||||||
|
if ($row['reactType']==0) // todo : to keep ?
|
||||||
|
$totalPrecision += $row['hitPrecision'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->score = calculate_v1($totalPrecision, $nbCivilHits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = "UPDATE " . $this->table_name . " SET score=:score WHERE sessionId=:sessionId AND userId=:userId";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt->bindParam(":userId", $this->userId);
|
||||||
|
$stmt->bindParam(":score", $this->score);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// update overall averages for participation user
|
||||||
|
$query_Precision = "SELECT X.UserId AS UserId, SUM(X.NbShotsFired) AS nbFire, SUM(Y.HitPrecision) AS precisionTotal FROM " . "
|
||||||
|
(SELECT P.userID AS UserId, P.sessionId AS SessionId, TE.indexCount AS IndexCount, COUNT(DISTINCT TE.sessionId, TE.indexCount) AS NbShotsFired
|
||||||
|
FROM " . PARTICIPATES_TABLE_NAME . " P, " . TRIGGEREVENTS_TABLE_NAME . " TE " . "
|
||||||
|
WHERE P.sessionId=TE.sessionId AND TE.srcUserId=P.UserId GROUP BY P.UserId, P.sessionId, TE.indexCount) AS X
|
||||||
|
LEFT JOIN
|
||||||
|
(SELECT P.userID AS UserId, P.sessionId AS SessionId, TE.indexCount AS IndexCount, MAX(RE.hitPrecision) AS HitPrecision
|
||||||
|
FROM " . PARTICIPATES_TABLE_NAME . " P, " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE
|
||||||
|
WHERE P.sessionId=TE.sessionId AND P.sessionId=RE.srcEventSessionId AND TE.srcUserId=P.UserId AND RE.srcEventIndex=TE.indexCount GROUP BY P.UserId, P.sessionId, TE.indexCount) AS Y
|
||||||
|
ON (X.UserId=Y.UserId AND X.SessionId=Y.SessionId AND X.IndexCount=Y.IndexCount) WHERE X.UserId=:userId
|
||||||
|
GROUP BY UserId";
|
||||||
|
|
||||||
|
$query_ReactionTime = "SELECT P.userID AS UserId, SUM(CASE WHEN RE.reactTime>0 THEN 1 ELSE 0 END) AS nbHits, SUM(RE.reactTime) AS reactTimeTotal
|
||||||
|
FROM " . PARTICIPATES_TABLE_NAME . " P, " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE
|
||||||
|
WHERE P.sessionId=TE.sessionId AND P.sessionId=RE.srcEventSessionId AND TE.srcUserId=P.UserId AND RE.srcEventIndex=TE.indexCount AND P.UserId=:userId GROUP BY P.UserId";
|
||||||
|
|
||||||
|
$stmt_Precision = $this->conn->prepare($query_Precision);
|
||||||
|
$stmt_ReactionTime = $this->conn->prepare($query_ReactionTime);
|
||||||
|
|
||||||
|
$stmt_Precision->bindParam(":userId", $this->userId);
|
||||||
|
$stmt_ReactionTime->bindParam(":userId", $this->userId);
|
||||||
|
|
||||||
|
$stmt_Precision->execute();
|
||||||
|
$stmt_ReactionTime->execute();
|
||||||
|
|
||||||
|
$res_UpdatePrecision = true;
|
||||||
|
$res_UpdateReactionTime = true;
|
||||||
|
|
||||||
|
if ($stmt_Precision->rowCount() > 0 && $this->userId > 0)
|
||||||
|
{
|
||||||
|
// get retrieved row
|
||||||
|
$row = $stmt_Precision->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$shotFired = $row['nbFire'];
|
||||||
|
|
||||||
|
// retrieve average precision per shot
|
||||||
|
$userPrecision = $row['precisionTotal']/($shotFired == 0 ? 1 : $shotFired);
|
||||||
|
|
||||||
|
$updatePrecision = "UPDATE " . USERS_TABLE_NAME . " SET avgPrecision=" . $userPrecision . " WHERE id=" . $this->userId;
|
||||||
|
|
||||||
|
$stmt_UpdatePrecision = $this->conn->prepare($updatePrecision);
|
||||||
|
$res_UpdatePrecision = $stmt_UpdatePrecision->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stmt_ReactionTime->rowCount() > 0 && $this->userId > 0)
|
||||||
|
{
|
||||||
|
// get retrieved row
|
||||||
|
$row = $stmt_ReactionTime->fetch(PDO::FETCH_ASSOC);
|
||||||
|
$shotHits = $row['nbHits'];
|
||||||
|
|
||||||
|
// retrieve average reactionTime per shot
|
||||||
|
$userReactionTime = $row['reactTimeTotal']/($shotHits == 0 ? 1 : $shotHits);
|
||||||
|
|
||||||
|
$updateReactionTime = "UPDATE " . USERS_TABLE_NAME . " SET avgReaction=" . $userReactionTime . " WHERE id=" . $this->userId;
|
||||||
|
$stmt_UpdateReactionTime = $this->conn->prepare($updateReactionTime);
|
||||||
|
$res_UpdateReactionTime = $stmt_UpdateReactionTime->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $stmt->execute() && $res_UpdatePrecision && $res_UpdateReactionTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function updateObjectives ()
|
||||||
|
{
|
||||||
|
// load current values
|
||||||
|
//$this->load();
|
||||||
|
|
||||||
|
// sanitize JSON string
|
||||||
|
//$this->results=htmlspecialchars(strip_tags($this->results));
|
||||||
|
|
||||||
|
// update participation with objective results in database
|
||||||
|
$query = "UPDATE " . $this->table_name . " SET results=:results WHERE sessionId=:sessionId AND userId=:userId";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt->bindParam(":userId", $this->userId);
|
||||||
|
$stmt->bindParam(":results", $this->results);
|
||||||
|
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function userLeaves ()
|
||||||
|
{
|
||||||
|
//$this->userId = $userId;
|
||||||
|
//$this->sessionId = $sessionId;
|
||||||
|
|
||||||
|
// load current values
|
||||||
|
$this->load();
|
||||||
|
|
||||||
|
// queries to update user stats
|
||||||
|
$query_NbShotFired = "SELECT COUNT(DISTINCT TE.indexCount) AS nbFire FROM " . TRIGGEREVENTS_TABLE_NAME . " TE WHERE TE.sessionId=:sessionId AND TE.srcUserId=:userId";
|
||||||
|
$query_NbEnemyHit = "SELECT COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS nbEnemyHits FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId=:sessionId AND RE.srcEventSessionId=TE.sessionId AND RE.srcEventIndex=TE.indexCount AND TE.srcUserId=:userId AND RE.reactType=0";
|
||||||
|
$query_NbCivilHit = "SELECT COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS nbCivilHits FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId=:sessionId AND RE.srcEventSessionId=TE.sessionId AND RE.srcEventIndex=TE.indexCount AND TE.srcUserId=:userId AND RE.reactType=1";
|
||||||
|
$query_PrecisionAndReactionTime = "SELECT SUM(X.precisionByShot) AS precisionTotal, SUM(X.reactTimeByShot) AS reactTimeTotal, COUNT(CASE WHEN X.reactTime > 0 THEN 1 ELSE 0 END) AS shots
|
||||||
|
FROM (SELECT COALESCE(RE.hitPrecision,0) AS precisionByShot, COALESCE(RE.reactTime,0) AS reactTimeByShot, RE.reactTime AS reactTime FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId=:sessionId AND TE.srcUserId=:userId AND TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND (RE.reactType=0 OR RE.reactType=4 OR RE.reactType=5) GROUP BY TE.sessionId, TE.srcUserId, TE.indexCount) AS X";
|
||||||
|
|
||||||
|
// prepare queries
|
||||||
|
$stmt_NbShotFired = $this->conn->prepare($query_NbShotFired);
|
||||||
|
$stmt_NbEnemyHit = $this->conn->prepare($query_NbEnemyHit);
|
||||||
|
$stmt_NbCivilHit = $this->conn->prepare($query_NbCivilHit);
|
||||||
|
$stmt_PrecisionAndReactionTime = $this->conn->prepare($query_PrecisionAndReactionTime);
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt_NbShotFired->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt_NbShotFired->bindParam(":userId", $this->userId);
|
||||||
|
$stmt_NbEnemyHit->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt_NbEnemyHit->bindParam(":userId", $this->userId);
|
||||||
|
$stmt_NbCivilHit->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt_NbCivilHit->bindParam(":userId", $this->userId);
|
||||||
|
$stmt_PrecisionAndReactionTime->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt_PrecisionAndReactionTime->bindParam(":userId", $this->userId);
|
||||||
|
|
||||||
|
// execute queries
|
||||||
|
$stmt_NbShotFired->execute();
|
||||||
|
$stmt_NbEnemyHit->execute();
|
||||||
|
$stmt_NbCivilHit->execute();
|
||||||
|
$stmt_PrecisionAndReactionTime->execute();
|
||||||
|
|
||||||
|
|
||||||
|
$shotFired=1;
|
||||||
|
if ($stmt_NbShotFired->rowCount() > 0)
|
||||||
|
{
|
||||||
|
// get retrieved row
|
||||||
|
$row = $stmt_NbShotFired->fetch(PDO::FETCH_ASSOC);
|
||||||
|
// retrieve nbFire value
|
||||||
|
$shotFired = $row['nbFire'];
|
||||||
|
}
|
||||||
|
if ($stmt_NbEnemyHit->rowCount() > 0)
|
||||||
|
{
|
||||||
|
// get retrieved row
|
||||||
|
$row = $stmt_NbEnemyHit->fetch(PDO::FETCH_ASSOC);
|
||||||
|
// retrieve nbEnemyHits value
|
||||||
|
$this->nbEnemyHit = $row['nbEnemyHits'];
|
||||||
|
}
|
||||||
|
if ($stmt_NbCivilHit->rowCount() > 0)
|
||||||
|
{
|
||||||
|
// get retrieved row
|
||||||
|
$row = $stmt_NbCivilHit->fetch(PDO::FETCH_ASSOC);
|
||||||
|
// retrieve nbCivilHits value
|
||||||
|
$this->nbCivilsHit = $row['nbCivilHits'];
|
||||||
|
}
|
||||||
|
if ($stmt_PrecisionAndReactionTime->rowCount() > 0)
|
||||||
|
{
|
||||||
|
// get retrieved row
|
||||||
|
$row = $stmt_PrecisionAndReactionTime->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$shotsWithReactTime = (int)$row['shots'];
|
||||||
|
if ($shotsWithReactTime <= 0)
|
||||||
|
$shotsWithReactTime = 1;
|
||||||
|
|
||||||
|
// retrieve average precision per shot
|
||||||
|
$this->firePrecision = $row['precisionTotal']/($shotFired == 0 ? 1 : $shotFired);
|
||||||
|
// retrieve average reactionTime per shot
|
||||||
|
$this->reactionTime = $row['reactTimeTotal']/$shotsWithReactTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->update() && $stmt_NbShotFired->rowCount() > 0 && $stmt_NbEnemyHit->rowCount() > 0 && $stmt_NbCivilHit->rowCount() > 0 && $stmt_PrecisionAndReactionTime->rowCount() > 0 /* && $stmt_Precision->rowCount() > 0 && $stmt_ReactionTime->rowCount() > 0*/;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function update ()
|
||||||
|
{
|
||||||
|
$query = "UPDATE " . $this->table_name . "
|
||||||
|
SET nbEnemyHit=:nbEnemyHit, nbCivilsHit=:nbCivilsHit, firePrecision=:firePrecision, reactionTime=:reactionTime " . "
|
||||||
|
WHERE sessionId='".$this->sessionId."' AND userId='".$this->userId."'";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":nbEnemyHit", $this->nbEnemyHit);
|
||||||
|
$stmt->bindParam(":nbCivilsHit", $this->nbCivilsHit);
|
||||||
|
$stmt->bindParam(":firePrecision", $this->firePrecision);
|
||||||
|
$stmt->bindParam(":reactionTime", $this->reactionTime);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
if ($stmt->execute())
|
||||||
|
{
|
||||||
|
// once it has been updated, we have to update the averagePrecision field in the USERS_TABLE_NAME table
|
||||||
|
$query = "UPDATE " . USERS_TABLE_NAME . "
|
||||||
|
SET avgPrecision=(SELECT AVG(firePrecision) FROM " . PARTICIPATES_TABLE_NAME . " P WHERE P.userId='".$this->userId."'),
|
||||||
|
avgReaction=(SELECT AVG(reactionTime) FROM " . PARTICIPATES_TABLE_NAME . " P WHERE P.userId='".$this->userId."')
|
||||||
|
WHERE users.id='".$this->userId."'";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function updateScore ()
|
||||||
|
{
|
||||||
|
$query = "UPDATE " . $this->table_name . " SET score=:score WHERE sessionId=:sessionId AND userId=:userId";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt->bindParam(":userId", $this->userId);
|
||||||
|
$stmt->bindParam(":score", $this->score);
|
||||||
|
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function getAllUsersInSession ()
|
||||||
|
{
|
||||||
|
$query = "SELECT DISTINCT userId FROM " . $this->table_name . " WHERE sessionId=:sessionId";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":sessionId", $this->sessionId);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
$usersIdForSession = array();
|
||||||
|
|
||||||
|
// loop through results
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
// retrieve user ids
|
||||||
|
array_push( $usersIdForSession, $row['userId'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $usersIdForSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function copy (UserInGameSession $otherParticipation)
|
||||||
|
{
|
||||||
|
$this->userId = $otherParticipation->userId;
|
||||||
|
$this->sessionId = $otherParticipation->sessionId;
|
||||||
|
|
||||||
|
$this->score = $otherParticipation->score; // need to call calculateAverages manually
|
||||||
|
$this->firePrecision = $otherParticipation->firePrecision; // need to call calculateAverages manually
|
||||||
|
$this->reactionTime = $otherParticipation->reactionTime; // need to call calculateAverages manually
|
||||||
|
|
||||||
|
$this->nbEnemyHit = $otherParticipation->nbEnemyHit; // total
|
||||||
|
$this->nbCivilsHit = $otherParticipation->nbCivilsHit; // total
|
||||||
|
$this->damageTaken = $otherParticipation->damageTaken; // total
|
||||||
|
|
||||||
|
$this->endStatus = $otherParticipation->endStatus; // TODO : mixed ?
|
||||||
|
$this->avatar = $otherParticipation->avatar;
|
||||||
|
$this->weapon = $otherParticipation->weapon;
|
||||||
|
$this->role = $otherParticipation->role; // TODO
|
||||||
|
|
||||||
|
if ($this->results != null && $otherParticipation->results != null)
|
||||||
|
$this->results = $otherParticipation->results;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function add (UserInGameSession $otherParticipation)
|
||||||
|
{
|
||||||
|
if ($this->userId != $otherParticipation->userId)
|
||||||
|
$this->userId = -1;
|
||||||
|
|
||||||
|
if ($this->sessionId != $otherParticipation->sessionId)
|
||||||
|
$this->sessionId = -1;
|
||||||
|
|
||||||
|
$this->score += $otherParticipation->score; // need to call calculateAverages manually
|
||||||
|
$this->firePrecision += $otherParticipation->firePrecision; // need to call calculateAverages manually
|
||||||
|
$this->reactionTime += $otherParticipation->reactionTime; // need to call calculateAverages manually
|
||||||
|
|
||||||
|
$this->nbEnemyHit += $otherParticipation->nbEnemyHit; // total
|
||||||
|
$this->nbCivilsHit += $otherParticipation->nbCivilsHit; // total
|
||||||
|
$this->damageTaken += $otherParticipation->damageTaken; // total
|
||||||
|
|
||||||
|
$this->endStatus = $this->endStatus; // TODO : mixed ?
|
||||||
|
if ($this->avatar != $otherParticipation->avatar)
|
||||||
|
$this->avatar = "various avatars";
|
||||||
|
if ($this->weapon != $otherParticipation->weapon)
|
||||||
|
$this->weapon = "various weapons";
|
||||||
|
$this->role = $this->role; // TODO
|
||||||
|
|
||||||
|
$this->addResults($otherParticipation);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function addResults (UserInGameSession $otherParticipation)
|
||||||
|
{
|
||||||
|
// add resultsObject debriefs
|
||||||
|
if ($this->results != null && $otherParticipation->results != null)
|
||||||
|
{
|
||||||
|
$resultsObject = UserInGameSessionResults::fromJsonString($this->results);
|
||||||
|
$otherParticipationResultsObject = UserInGameSessionResults::fromJsonString($otherParticipation->results);
|
||||||
|
|
||||||
|
if ($resultsObject != null && $otherParticipationResultsObject)
|
||||||
|
{
|
||||||
|
$resultsObject->add($otherParticipationResultsObject);
|
||||||
|
|
||||||
|
// update results string
|
||||||
|
$this->results = json_encode($resultsObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function calculateAverages ($nb)
|
||||||
|
{
|
||||||
|
if ($nb > 0)
|
||||||
|
{
|
||||||
|
$this->score /= $nb; // average
|
||||||
|
$this->firePrecision /= $nb; // average
|
||||||
|
$this->reactionTime /= $nb; // average
|
||||||
|
$this->nbEnemyHit = $this->nbEnemyHit; // total
|
||||||
|
$this->nbCivilsHit = $this->nbCivilsHit; // total
|
||||||
|
$this->damageTaken = $this->damageTaken; // total
|
||||||
|
|
||||||
|
// calculate averages for scores of resultsObject
|
||||||
|
$resultsObject = UserInGameSessionResults::fromJsonString($this->results);
|
||||||
|
$resultsObject->calculateAverages($nb);
|
||||||
|
|
||||||
|
// update results string
|
||||||
|
$this->results = json_encode($resultsObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
// Single ObjectiveDebrief
|
||||||
|
class ObjectiveDebrief
|
||||||
|
{
|
||||||
|
public $id = "";
|
||||||
|
public $description = "";
|
||||||
|
public float $score = 0.0;
|
||||||
|
public float $weight = 0.0;
|
||||||
|
public int $completed = 0;
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function add (ObjectiveDebrief $otherDebrief)
|
||||||
|
{
|
||||||
|
if ($this->id == $otherDebrief->id)
|
||||||
|
{
|
||||||
|
// only add stats with same id
|
||||||
|
$this->score += $otherDebrief->score;
|
||||||
|
//$this->weight += $otherDebrief->weight; // weight should be the same if ids are the same
|
||||||
|
if ($this->completed != 1)
|
||||||
|
$this->completed = $otherDebrief->completed == 1 ? 1 : $this->completed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// General GameSession debrief (multiple ObjectiveDebrief)
|
||||||
|
class UserInGameSessionResults
|
||||||
|
{
|
||||||
|
public ObjectiveDebrief $civilian;
|
||||||
|
public ObjectiveDebrief $time;
|
||||||
|
public ObjectiveDebrief $enemy;
|
||||||
|
public ObjectiveDebrief $health;
|
||||||
|
public ObjectiveDebrief $precision;
|
||||||
|
public ObjectiveDebrief $reactTime;
|
||||||
|
public ObjectiveDebrief $ammoLimit;
|
||||||
|
public ObjectiveDebrief $target;
|
||||||
|
public ObjectiveDebrief $overall;
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function __construct ()
|
||||||
|
{
|
||||||
|
$this->civilian = new ObjectiveDebrief();
|
||||||
|
$this->time = new ObjectiveDebrief();
|
||||||
|
$this->enemy = new ObjectiveDebrief();
|
||||||
|
$this->health = new ObjectiveDebrief();
|
||||||
|
$this->precision = new ObjectiveDebrief();
|
||||||
|
$this->reactTime = new ObjectiveDebrief();
|
||||||
|
$this->ammoLimit = new ObjectiveDebrief();
|
||||||
|
$this->target = new ObjectiveDebrief();
|
||||||
|
$this->overall = new ObjectiveDebrief();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public static function fromJsonString (string $jsonResults)
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
|
||||||
|
if (!empty($jsonResults))
|
||||||
|
$instance->createFromJsonArray( json_decode($jsonResults, true) );
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
private function createFromJsonArray ($missionDebriefData)
|
||||||
|
{
|
||||||
|
// Parse all objectiveDebrief JSON entries in data (civilian, time, enemy, etc.)
|
||||||
|
foreach ($missionDebriefData as $objectiveDebriefKey => $objectiveDebriefValue)
|
||||||
|
{
|
||||||
|
// Create an ObjectiveDebrief variable for each objectiveDebrief JSON entry
|
||||||
|
$debrief = new ObjectiveDebrief();
|
||||||
|
foreach ($objectiveDebriefValue as $debriefKey => $debriefValue)
|
||||||
|
{
|
||||||
|
// Read all JSON properties and assign them to the ObjectiveDebrief variable
|
||||||
|
$debrief->{$debriefKey} = $debriefValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign ObjectiveDebrief variable to the corresponding property in UserInGameSessionResults
|
||||||
|
$this->{$objectiveDebriefKey} = $debrief;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function add (UserInGameSessionResults $otherParticipationResults)
|
||||||
|
{
|
||||||
|
$this->civilian->add($otherParticipationResults->civilian);
|
||||||
|
$this->time->add($otherParticipationResults->time);
|
||||||
|
$this->enemy->add($otherParticipationResults->enemy);
|
||||||
|
$this->health->add($otherParticipationResults->health);
|
||||||
|
$this->precision->add($otherParticipationResults->precision);
|
||||||
|
$this->reactTime->add($otherParticipationResults->reactTime);
|
||||||
|
$this->ammoLimit->add($otherParticipationResults->ammoLimit);
|
||||||
|
$this->target->add($otherParticipationResults->target);
|
||||||
|
$this->overall->add($otherParticipationResults->overall);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function calculateAverages ($nb)
|
||||||
|
{
|
||||||
|
$this->civilian->score /= $nb;
|
||||||
|
$this->time->score /= $nb;
|
||||||
|
$this->enemy->score /= $nb;
|
||||||
|
$this->health->score /= $nb;
|
||||||
|
$this->precision->score /= $nb;
|
||||||
|
$this->reactTime->score /= $nb;
|
||||||
|
$this->ammoLimit->score /= $nb;
|
||||||
|
$this->target->score /= $nb;
|
||||||
|
$this->overall->score /= $nb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
149
version/proserve-ReleaseTemplate/_api/objects/db_reactevent.php
Normal file
149
version/proserve-ReleaseTemplate/_api/objects/db_reactevent.php
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/db_table_object.php';
|
||||||
|
include_once '../objects/db_triggerevent.php';
|
||||||
|
|
||||||
|
class ReactEvent extends DBTableObject
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
protected $table_name = REACTEVENTS_TABLE_NAME;
|
||||||
|
protected $array_key = "reactevent";
|
||||||
|
|
||||||
|
// object properties
|
||||||
|
public int $id = -1;
|
||||||
|
public int $srcEventIndex = -1;
|
||||||
|
public int $srcEventSessionId = -1;
|
||||||
|
public int $reactType = -1;
|
||||||
|
public int $reactMode = -1;
|
||||||
|
public int $hitUserId = -1;
|
||||||
|
public string $hitTargetName = "";
|
||||||
|
public string $hitBoneName = "";
|
||||||
|
public float $damage = 0.0;
|
||||||
|
public int $targetKilled = 0;
|
||||||
|
public float$objectHitLocationX = 0.0;
|
||||||
|
public float $objectHitLocationY = 0.0;
|
||||||
|
public string $objectHitTagLocation = "";
|
||||||
|
public float $hitPrecision = 0.0;
|
||||||
|
public float $distance = 0.0;
|
||||||
|
public float $reactTime = 0.0;
|
||||||
|
public float $timeStamp = 0.0;
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"id" => (int)$this->id,
|
||||||
|
"srcEventIndex" => (int)$this->srcEventIndex,
|
||||||
|
"srcEventSessionId" => (int)$this->srcEventSessionId,
|
||||||
|
"reactTypeAsInt" => (int)$this->reactType,
|
||||||
|
"hitUserId" => (int)$this->hitUserId,
|
||||||
|
"hitTargetName" => $this->hitTargetName ?? "",
|
||||||
|
"hitBoneName" => $this->hitBoneName ?? "",
|
||||||
|
"damage" => (float)$this->damage ?? 0.0,
|
||||||
|
"targetKilled" => ($this->targetKilled ?? 0) == 1 ? true : false,
|
||||||
|
"objectHitLocationX" => (float)$this->objectHitLocationX ?? 0.0,
|
||||||
|
"objectHitLocationY" => (float)$this->objectHitLocationY ?? 0.0,
|
||||||
|
"objectHitTagLocation" => $this->objectHitTagLocation ?? "",
|
||||||
|
"hitPrecision" => (float)$this->hitPrecision ?? 0.0,
|
||||||
|
"timestamp" => (float)$this->timeStamp ?? 0.0,
|
||||||
|
"distance" => (float)$this->distance ?? 0.0,
|
||||||
|
"reactTime" => (float)$this->reactTime ?? 0.0,
|
||||||
|
"reactModeAsInt" => (int)$this->reactMode ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function sanitize ()
|
||||||
|
{
|
||||||
|
$this->hitUserId=htmlspecialchars(strip_tags($this->hitUserId));
|
||||||
|
$this->srcEventIndex=htmlspecialchars(strip_tags($this->srcEventIndex));
|
||||||
|
$this->srcEventSessionId=htmlspecialchars(strip_tags($this->srcEventSessionId));
|
||||||
|
$this->reactType=htmlspecialchars(strip_tags($this->reactType));
|
||||||
|
$this->hitTargetName=htmlspecialchars(strip_tags($this->hitTargetName));
|
||||||
|
$this->hitBoneName=htmlspecialchars(strip_tags($this->hitBoneName));
|
||||||
|
$this->damage=htmlspecialchars(strip_tags($this->damage));
|
||||||
|
$this->targetKilled=htmlspecialchars(strip_tags($this->targetKilled));
|
||||||
|
$this->objectHitLocationX=htmlspecialchars(strip_tags($this->objectHitLocationX));
|
||||||
|
$this->objectHitLocationY=htmlspecialchars(strip_tags($this->objectHitLocationY));
|
||||||
|
$this->objectHitTagLocation=htmlspecialchars(strip_tags($this->objectHitTagLocation));
|
||||||
|
$this->hitPrecision=htmlspecialchars(strip_tags($this->hitPrecision));
|
||||||
|
$this->distance=htmlspecialchars(strip_tags($this->distance));
|
||||||
|
$this->reactTime=htmlspecialchars(strip_tags($this->reactTime));
|
||||||
|
$this->reactMode=htmlspecialchars(strip_tags($this->reactMode));
|
||||||
|
$this->timeStamp=htmlspecialchars(strip_tags($this->timeStamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function record ()
|
||||||
|
{
|
||||||
|
$canAddReactEvent = true;
|
||||||
|
|
||||||
|
// check that given srcEventId exists, otherwise create it
|
||||||
|
$query = "SELECT T.indexCount, T.sessionId FROM " . TRIGGEREVENTS_TABLE_NAME . " T WHERE T.indexCount=" . $this->srcEventIndex . " AND T.sessionId=" . $this->srcEventSessionId;
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
if($stmt->rowCount() == 0)
|
||||||
|
{
|
||||||
|
$canAddReactEvent = false;
|
||||||
|
|
||||||
|
$event = new TriggerEvent($this->conn);
|
||||||
|
$event->indexCount = $this->srcEventIndex;
|
||||||
|
$event->sessionId = $this->srcEventSessionId;
|
||||||
|
$event->srcUserId = 0;
|
||||||
|
$event->type = 0; // Fire
|
||||||
|
$event->successful = 1;
|
||||||
|
$event->timeStamp = $this->timeStamp;
|
||||||
|
|
||||||
|
if ($event->recordFromReact())
|
||||||
|
$canAddReactEvent = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($canAddReactEvent)
|
||||||
|
{
|
||||||
|
// query to insert record of new user signup
|
||||||
|
$query = "INSERT INTO " . $this->table_name . "
|
||||||
|
SET srcEventIndex=:srcEventIndex, srcEventSessionId=:srcEventSessionId, reactType=:reactType, reactMode=:reactMode, " . "
|
||||||
|
hitUserId=:hitUserId, hitTargetName=:hitTargetName, hitBoneName=:hitBoneName, damage=:damage, targetKilled=:targetKilled, " . "
|
||||||
|
objectHitLocationX=:objectHitLocationX, objectHitLocationY=:objectHitLocationY, objectHitTagLocation=:objectHitTagLocation, " . "
|
||||||
|
hitPrecision=:hitPrecision, distance=:distance, timeStamp=:timeStamp, reactTime=:reactTime";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// sanitize
|
||||||
|
$this->sanitize();
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":hitUserId", $this->hitUserId);
|
||||||
|
$stmt->bindParam(":srcEventIndex", $this->srcEventIndex);
|
||||||
|
$stmt->bindParam(":srcEventSessionId", $this->srcEventSessionId);
|
||||||
|
$stmt->bindParam(":reactType", $this->reactType);
|
||||||
|
$stmt->bindParam(":hitTargetName", $this->hitTargetName);
|
||||||
|
$stmt->bindParam(":hitBoneName", $this->hitBoneName);
|
||||||
|
$stmt->bindParam(":damage", $this->damage);
|
||||||
|
$stmt->bindParam(":targetKilled", $this->targetKilled);
|
||||||
|
$stmt->bindParam(":objectHitTagLocation", $this->objectHitTagLocation);
|
||||||
|
$stmt->bindParam(":objectHitLocationX", $this->objectHitLocationX);
|
||||||
|
$stmt->bindParam(":objectHitLocationY", $this->objectHitLocationY);
|
||||||
|
$stmt->bindParam(":hitPrecision", $this->hitPrecision);
|
||||||
|
$stmt->bindParam(":distance", $this->distance);
|
||||||
|
$stmt->bindParam(":reactTime", $this->reactTime);
|
||||||
|
$stmt->bindParam(":reactMode", $this->reactMode);
|
||||||
|
$stmt->bindParam(":timeStamp", $this->timeStamp);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
if ($stmt->execute())
|
||||||
|
{
|
||||||
|
$this->id = $this->conn->lastInsertId();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/db_event_type.php';
|
||||||
|
|
||||||
|
class ReactEventType extends DBObjectType
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
protected $table_name = REACTEVENTMODES_TABLE_NAME;
|
||||||
|
//protected $array_key = "type";
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/db_event_type.php';
|
||||||
|
|
||||||
|
class ReactEventType extends DBObjectType
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
protected $table_name = REACTEVENTTYPES_TABLE_NAME;
|
||||||
|
//protected $array_key = "type";
|
||||||
|
}
|
||||||
|
?>
|
||||||
200
version/proserve-ReleaseTemplate/_api/objects/db_session.php
Normal file
200
version/proserve-ReleaseTemplate/_api/objects/db_session.php
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
include_once "../objects/db_table_object.php";
|
||||||
|
include_once "../objects/db_participates.php";
|
||||||
|
|
||||||
|
class GameSession extends DBTableObject
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
protected $table_name = SESSIONS_TABLE_NAME;
|
||||||
|
protected $array_key = "session";
|
||||||
|
|
||||||
|
|
||||||
|
// object properties
|
||||||
|
public int $id = -1;
|
||||||
|
public $sessionType = 0;
|
||||||
|
public string $sessionName = "";
|
||||||
|
public $sessionDate = "";
|
||||||
|
public string $mapName = "";
|
||||||
|
public string $scenarioName = "";
|
||||||
|
public $success = 0;
|
||||||
|
public float $timeToFinish = 0.0;
|
||||||
|
public $score = 0;
|
||||||
|
public int $nbEnemyHit = 0;
|
||||||
|
public int $nbCivilsHit = 0;
|
||||||
|
public float $damageTaken = 0.0;
|
||||||
|
public $replayFileName = "";
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"id" => (int)$this->id,
|
||||||
|
"sessionTypeAsInt" => (int)$this->sessionType ?? 0,
|
||||||
|
"sessionName" => $this->sessionName ?? "",
|
||||||
|
"sessionDateAsString" => $this->sessionDate,
|
||||||
|
"mapName" => $this->mapName ?? "",
|
||||||
|
"scenarioName" => $this->scenarioName ?? "",
|
||||||
|
"success" => $this->success == 1 ? true : false,
|
||||||
|
"timeToFinish" => (float)$this->timeToFinish ?? 0.0,
|
||||||
|
"score" => (int)$this->score ?? 0,
|
||||||
|
"nbEnemyHit" => (int)$this->nbEnemyHit ?? 0,
|
||||||
|
"nbCivilsHit" => (int)$this->nbCivilsHit ?? 0,
|
||||||
|
"damageTaken" => (float)$this->damageTaken ?? 0.0,
|
||||||
|
"replayFileName" => $this->replayFileName ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public static function withRow ($db, array $row)
|
||||||
|
{
|
||||||
|
$instance = new self($db);
|
||||||
|
$instance->readRow($row);
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function readRow (array $row)
|
||||||
|
{
|
||||||
|
$this->id = (int)$row['id'];
|
||||||
|
$this->sessionType = $row['sessionType'];
|
||||||
|
$this->sessionName = $row['sessionName'];
|
||||||
|
$this->sessionDate = $row['sessionDate'];
|
||||||
|
$this->mapName = $row['mapName'];
|
||||||
|
$this->scenarioName = $row['scenarioName'];
|
||||||
|
$this->success = $row['success'];
|
||||||
|
$this->timeToFinish = (float)$row['timeToFinish'];
|
||||||
|
$this->score = $row['score'];
|
||||||
|
$this->nbEnemyHit = (int)$row['nbEnemyHit'];
|
||||||
|
$this->nbCivilsHit = (int)$row['nbCivilsHit'];
|
||||||
|
$this->damageTaken = (float)$row['damageTaken'];
|
||||||
|
$this->replayFileName = $row['replayFileName'];
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function load ()
|
||||||
|
{
|
||||||
|
// select all query with user inputed username and password
|
||||||
|
$query = "SELECT * FROM " . $this->table_name . " WHERE id='" . $this->id . "'";
|
||||||
|
|
||||||
|
// prepare query statement
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
if($stmt->rowCount() > 0)
|
||||||
|
{
|
||||||
|
// get retrieved row
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
// retrieve user values
|
||||||
|
$this->readRow($row);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function sanitize ()
|
||||||
|
{
|
||||||
|
$this->sessionType=htmlspecialchars(strip_tags($this->sessionType));
|
||||||
|
$this->sessionName=htmlspecialchars(strip_tags($this->sessionName));
|
||||||
|
$this->sessionDate=htmlspecialchars(strip_tags($this->sessionDate));
|
||||||
|
$this->mapName=htmlspecialchars(strip_tags($this->mapName));
|
||||||
|
$this->scenarioName=htmlspecialchars(strip_tags($this->scenarioName));
|
||||||
|
$this->replayFileName=htmlspecialchars(strip_tags($this->replayFileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function sanitize_stop()
|
||||||
|
{
|
||||||
|
$this->success=htmlspecialchars(strip_tags($this->success));
|
||||||
|
$this->timeToFinish=htmlspecialchars(strip_tags($this->timeToFinish));
|
||||||
|
$this->score=htmlspecialchars(strip_tags($this->score));
|
||||||
|
//$this->id=htmlspecialchars(strip_tags($this->id));
|
||||||
|
//$this->nbEnemyHit=htmlspecialchars(strip_tags($this->nbEnemyHit));
|
||||||
|
//$this->nbCivilsHit=htmlspecialchars(strip_tags($this->nbCivilsHit));
|
||||||
|
//$this->damageTaken=htmlspecialchars(strip_tags($this->damageTaken));
|
||||||
|
//$this->replayFileName=htmlspecialchars(strip_tags($this->replayFileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function start ()
|
||||||
|
{
|
||||||
|
// query to insert record of new user signup
|
||||||
|
$query = "INSERT INTO " . $this->table_name . "
|
||||||
|
SET sessionType=:sessionType, sessionName=:sessionName, sessionDate=:sessionDate, mapName=:mapName, scenarioName=:scenarioName, " . "
|
||||||
|
replayFileName=:replayFileName";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// sanitize
|
||||||
|
$this->sanitize();
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":sessionType", $this->sessionType);
|
||||||
|
$stmt->bindParam(":sessionName", $this->sessionName);
|
||||||
|
$stmt->bindParam(":sessionDate", $this->sessionDate);
|
||||||
|
$stmt->bindParam(":mapName", $this->mapName);
|
||||||
|
$stmt->bindParam(":scenarioName", $this->scenarioName);
|
||||||
|
$stmt->bindParam(":replayFileName", $this->replayFileName);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
if($stmt->execute())
|
||||||
|
{
|
||||||
|
$this->id = $this->conn->lastInsertId();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function stop ()
|
||||||
|
{
|
||||||
|
//if (this->load())
|
||||||
|
{
|
||||||
|
$query = "UPDATE " . $this->table_name . "
|
||||||
|
SET success=:success, timeToFinish=:timeToFinish, score=:score, " . "
|
||||||
|
nbEnemyHit=(SELECT COUNT(DISTINCT Re.srcEventIndex, Re.hitTargetName) FROM " . REACTEVENTS_TABLE_NAME . " Re WHERE Re.srcEventSessionId=:id AND Re.reactType=0), " . "
|
||||||
|
nbCivilsHit=(SELECT COUNT(DISTINCT Rc.srcEventIndex, Rc.hitTargetName) FROM " . REACTEVENTS_TABLE_NAME . " Rc WHERE Rc.srcEventSessionId=:id AND Rc.reactType=1) " . "
|
||||||
|
WHERE id=:id";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// sanitize
|
||||||
|
$this->sanitize_stop();
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":id", $this->id);
|
||||||
|
$stmt->bindParam(":success", $this->success);
|
||||||
|
$stmt->bindParam(":timeToFinish", $this->timeToFinish);
|
||||||
|
$stmt->bindParam(":score", $this->score);
|
||||||
|
//$stmt->bindParam(":nbEnemyHit", $this->nbEnemyHit);
|
||||||
|
//$stmt->bindParam(":nbCivilsHit", $this->nbCivilsHit);
|
||||||
|
//$stmt->bindParam(":damageTaken", $this->damageTaken);
|
||||||
|
//$stmt->bindParam(":replayFileName", $this->replayFileName);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function getUsers ()
|
||||||
|
{
|
||||||
|
$query = "SELECT U.* FROM " . USERS_TABLE_NAME . " U, " . PARTICIPATES_TABLE_NAME . " P WHERE U.id=P.userId AND P.sessionId = '" . $this->id . "'";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/db_event_type.php';
|
||||||
|
|
||||||
|
class SessionType extends DBObjectType
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
protected $table_name = SESSIONTYPES_TABLE_NAME;
|
||||||
|
//protected $array_key = "type";
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../config/constants.php';
|
||||||
|
|
||||||
|
class DBTableObject
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
protected $conn;
|
||||||
|
protected $table_name = "";
|
||||||
|
protected $array_key = "";
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// constructor with $db as database connection
|
||||||
|
public function __construct($db)
|
||||||
|
{
|
||||||
|
$this->conn = $db;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function getResultArray ($status, $message) : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"status" => $status,
|
||||||
|
"message" => $message,
|
||||||
|
$this->array_key => $this->toArray()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function executeQuery ($query)
|
||||||
|
{
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/db_table_object.php';
|
||||||
|
|
||||||
|
class TriggerEvent extends DBTableObject
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
protected $table_name = TRIGGEREVENTS_TABLE_NAME;
|
||||||
|
protected $array_key = "triggerevent";
|
||||||
|
|
||||||
|
// object properties
|
||||||
|
public int $indexCount = -1;
|
||||||
|
public int $srcUserId = -1;
|
||||||
|
public int $sessionId = -1;
|
||||||
|
public int $type = -1;
|
||||||
|
public $successful = 0;
|
||||||
|
public float $timeStamp = 0.0;
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"indexCount" => (int)$this->indexCount,
|
||||||
|
"sessionId" => (int)$this->sessionId,
|
||||||
|
"srcUserId" => (int)$this->srcUserId,
|
||||||
|
"typeAsInt" => (int)$this->type,
|
||||||
|
"timeStamp" => (float)$this->timeStamp,
|
||||||
|
"successful" => $this->successful == 1 ? true : false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function sanitize ()
|
||||||
|
{
|
||||||
|
$this->indexCount=htmlspecialchars(strip_tags($this->indexCount));
|
||||||
|
$this->sessionId=htmlspecialchars(strip_tags($this->sessionId));
|
||||||
|
$this->srcUserId=htmlspecialchars(strip_tags($this->srcUserId));
|
||||||
|
$this->timeStamp=htmlspecialchars(strip_tags($this->timeStamp));
|
||||||
|
$this->successful=htmlspecialchars(strip_tags($this->successful));
|
||||||
|
$this->type=htmlspecialchars(strip_tags($this->type));
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function record ()
|
||||||
|
{
|
||||||
|
// query to insert record of new user signup
|
||||||
|
$query = "INSERT INTO " . $this->table_name . "
|
||||||
|
SET sessionId=:sessionId, indexCount=:indexCount, srcUserId=:srcUserId, type=:type, timeStamp=:timeStamp, successful=:successful
|
||||||
|
ON DUPLICATE KEY UPDATE srcUserId=:srcUserId, type=:type, timeStamp=:timeStamp, successful=:successful";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// sanitize
|
||||||
|
$this->sanitize();
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":indexCount", $this->indexCount);
|
||||||
|
$stmt->bindParam(":timeStamp", $this->timeStamp);
|
||||||
|
$stmt->bindParam(":successful", $this->successful);
|
||||||
|
$stmt->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt->bindParam(":srcUserId", $this->srcUserId);
|
||||||
|
$stmt->bindParam(":type", $this->type);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function recordFromReact ()
|
||||||
|
{
|
||||||
|
// query to insert record of new user signup
|
||||||
|
$query = "INSERT INTO " . $this->table_name . "
|
||||||
|
SET sessionId=:sessionId, indexCount=:indexCount, srcUserId=:srcUserId, type=:type, timeStamp=:timeStamp, successful=:successful
|
||||||
|
ON DUPLICATE KEY UPDATE indexCount=:indexCount";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// sanitize
|
||||||
|
$this->sanitize();
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":indexCount", $this->indexCount);
|
||||||
|
$stmt->bindParam(":timeStamp", $this->timeStamp);
|
||||||
|
$stmt->bindParam(":successful", $this->successful);
|
||||||
|
$stmt->bindParam(":sessionId", $this->sessionId);
|
||||||
|
$stmt->bindParam(":srcUserId", $this->srcUserId);
|
||||||
|
$stmt->bindParam(":type", $this->type);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/db_event_type.php';
|
||||||
|
|
||||||
|
class TriggerEventType extends DBObjectType
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
protected $table_name = TRIGGEREVENTTYPES_TABLE_NAME;
|
||||||
|
//protected $array_key = "type";
|
||||||
|
}
|
||||||
|
?>
|
||||||
300
version/proserve-ReleaseTemplate/_api/objects/db_user.php
Normal file
300
version/proserve-ReleaseTemplate/_api/objects/db_user.php
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/db_table_object.php';
|
||||||
|
|
||||||
|
class User extends DBTableObject
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
protected $table_name = USERS_TABLE_NAME;
|
||||||
|
protected $array_key = "user";
|
||||||
|
|
||||||
|
// object properties
|
||||||
|
public int $id = -1;
|
||||||
|
public string $username = "";
|
||||||
|
public string $password = "";
|
||||||
|
public string $firstName = "";
|
||||||
|
public string $lastName = "";
|
||||||
|
public $created = "";
|
||||||
|
public int $leftHanded = 0;
|
||||||
|
public int $maleGender = 1;
|
||||||
|
public string $charSkinAssetName = "";
|
||||||
|
public string $weaponAssetName = "";
|
||||||
|
public $lastConnection = "";
|
||||||
|
public float $avgPrecision = 0.0;
|
||||||
|
public float $avgReaction = 0.0;
|
||||||
|
public float $avgFault = 0.0;
|
||||||
|
public float $avgRapidity = 0.0;
|
||||||
|
public int $size = 0;
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public static function withRow ($db, array $row)
|
||||||
|
{
|
||||||
|
$instance = new self($db);
|
||||||
|
$instance->readRow($row);
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function readRow (array $row)
|
||||||
|
{
|
||||||
|
$this->id = (int)$row['id'];
|
||||||
|
$this->username = $row['username'];
|
||||||
|
$this->firstName = $row['firstName'];
|
||||||
|
$this->lastName = $row['lastName'];
|
||||||
|
$this->leftHanded = $row['leftHanded'];
|
||||||
|
$this->maleGender = $row['maleGender'];
|
||||||
|
$this->charSkinAssetName = $row['charSkinAssetName'];
|
||||||
|
$this->weaponAssetName = $row['weaponAssetName'];
|
||||||
|
$this->lastConnection = date('Y-m-d H:i:s');
|
||||||
|
$this->avgPrecision = (float)$row['avgPrecision'] ?? 0.0;
|
||||||
|
$this->avgReaction = (float)$row['avgReaction'] ?? 0.0;
|
||||||
|
$this->avgFault = (float)$row['avgFault'] ?? 0.0;
|
||||||
|
$this->avgRapidity = (float)$row['avgRapidity'] ?? 0.0;
|
||||||
|
$this->size = (int)$row['size'];
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"id" => (int)$this->id,
|
||||||
|
"username" => $this->username ?? "",
|
||||||
|
"firstName" => $this->firstName ?? "",
|
||||||
|
"lastName" => $this->lastName ?? "",
|
||||||
|
"leftHanded" => ($this->leftHanded ?? 0) == 1 ? true : false,
|
||||||
|
"maleGender" => ($this->maleGender ?? 1) == 1 ? true : false,
|
||||||
|
"charSkinAssetName" => $this->charSkinAssetName ?? "",
|
||||||
|
"weaponAssetName" => $this->weaponAssetName ?? "",
|
||||||
|
"lastConnection" => $this->lastConnection,
|
||||||
|
"avgPrecision" => (float)$this->avgPrecision ?? 0.0,
|
||||||
|
"avgReaction" => (float)$this->avgReaction ?? 0.0,
|
||||||
|
"avgFault" => (float)$this->avgFault ?? 0.0,
|
||||||
|
"avgRapidity" => (float)$this->avgRapidity ?? 0.0,
|
||||||
|
"size" => (int)$this->size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function sanitize ()
|
||||||
|
{
|
||||||
|
$this->username=htmlspecialchars(strip_tags($this->username));
|
||||||
|
$this->password=htmlspecialchars(strip_tags($this->password));
|
||||||
|
$this->created=htmlspecialchars(strip_tags($this->created));
|
||||||
|
$this->lastConnection=htmlspecialchars(strip_tags($this->lastConnection));
|
||||||
|
//$this->firstName=htmlspecialchars(strip_tags($this->firstName));
|
||||||
|
//$this->lastName=htmlspecialchars(strip_tags($this->lastName));
|
||||||
|
//$this->leftHanded=htmlspecialchars(strip_tags($this->leftHanded));
|
||||||
|
//$this->maleGender=htmlspecialchars(strip_tags($this->maleGender));
|
||||||
|
//$this->charSkinAssetName=htmlspecialchars(strip_tags($this->charSkinAssetName));
|
||||||
|
//$this->weaponAssetName=htmlspecialchars(strip_tags($this->weaponAssetName));
|
||||||
|
//$this->avgPrecision=htmlspecialchars(strip_tags($this->avgPrecision));
|
||||||
|
//$this->avgReaction=htmlspecialchars(strip_tags($this->avgReaction));
|
||||||
|
//$this->avgFault=htmlspecialchars(strip_tags($this->avgFault));
|
||||||
|
//$this->avgRapidity=htmlspecialchars(strip_tags($this->avgRapidity));
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function sanitize_update ()
|
||||||
|
{
|
||||||
|
//$this->username=htmlspecialchars(strip_tags($this->username));
|
||||||
|
//$this->password=htmlspecialchars(strip_tags($this->password));
|
||||||
|
//$this->avgPrecision=htmlspecialchars(strip_tags($this->avgPrecision));
|
||||||
|
//$this->avgReaction=htmlspecialchars(strip_tags($this->avgReaction));
|
||||||
|
//$this->avgFault=htmlspecialchars(strip_tags($this->avgFault));
|
||||||
|
//$this->avgRapidity=htmlspecialchars(strip_tags($this->avgRapidity));
|
||||||
|
$this->firstName=htmlspecialchars(strip_tags($this->firstName));
|
||||||
|
$this->lastName=htmlspecialchars(strip_tags($this->lastName));
|
||||||
|
$this->leftHanded=htmlspecialchars(strip_tags($this->leftHanded));
|
||||||
|
$this->maleGender=htmlspecialchars(strip_tags($this->maleGender));
|
||||||
|
$this->charSkinAssetName=htmlspecialchars(strip_tags($this->charSkinAssetName));
|
||||||
|
$this->weaponAssetName=htmlspecialchars(strip_tags($this->weaponAssetName));
|
||||||
|
$this->lastConnection=htmlspecialchars(strip_tags($this->lastConnection));
|
||||||
|
$this->size=htmlspecialchars(strip_tags($this->size));
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function load ()
|
||||||
|
{
|
||||||
|
// select all query with user inputed username and password
|
||||||
|
$query = "SELECT * FROM " . $this->table_name . " WHERE id='" . $this->id . "'";
|
||||||
|
|
||||||
|
// prepare query statement
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
if($stmt->rowCount() > 0)
|
||||||
|
{
|
||||||
|
// get retrieved row
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
// retrieve user values
|
||||||
|
$this->readRow($row);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
//user signup method
|
||||||
|
function signup ()
|
||||||
|
{
|
||||||
|
if ($this->isAlreadyExist()) return false;
|
||||||
|
|
||||||
|
// query to insert record of new user signup
|
||||||
|
$query = "INSERT INTO " . $this->table_name . " SET username=:username, password=:password, created=:created, lastConnection=:lastConnection";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// sanitize
|
||||||
|
$this->sanitize();
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":username", $this->username);
|
||||||
|
$stmt->bindParam(":password", $this->password);
|
||||||
|
$stmt->bindParam(":created", $this->created);
|
||||||
|
$stmt->bindParam(":lastConnection", $this->lastConnection);
|
||||||
|
//$stmt->bindParam(":firstName", $this->firstName);
|
||||||
|
//$stmt->bindParam(":lastName", $this->lastName);
|
||||||
|
//$stmt->bindParam(":leftHanded", $this->leftHanded);
|
||||||
|
//$stmt->bindParam(":maleGender", $this->maleGender);
|
||||||
|
//$stmt->bindParam(":charSkinAssetName", $this->charSkinAssetName);
|
||||||
|
//$stmt->bindParam(":weaponAssetName", $this->weaponAssetName);
|
||||||
|
//$stmt->bindParam(":avgPrecision", $this->avgPrecision);
|
||||||
|
//$stmt->bindParam(":avgReaction", $this->avgReaction);
|
||||||
|
//$stmt->bindParam(":avgFault", $this->avgFault);
|
||||||
|
//$stmt->bindParam(":avgRapidity", $this->avgRapidity);
|
||||||
|
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
if ($stmt->execute())
|
||||||
|
{
|
||||||
|
$this->id = $this->conn->lastInsertId();
|
||||||
|
return $this->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// login user method
|
||||||
|
function login ()
|
||||||
|
{
|
||||||
|
// select all query with user inputed username and password
|
||||||
|
$query = "SELECT * FROM " . $this->table_name . " WHERE BINARY username='".$this->username."' AND BINARY password='".$this->password."'";
|
||||||
|
|
||||||
|
// prepare query statement
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
//Update user infos
|
||||||
|
function refreshConnectionDate ()
|
||||||
|
{
|
||||||
|
// select all query with user inputed username and password
|
||||||
|
$query = "UPDATE " . $this->table_name . " SET lastConnection = '" . date('Y-m-d H:i:s') . "' WHERE id='" . $this->id . "'";
|
||||||
|
|
||||||
|
// prepare query statement
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
// execute query
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
//Notify if User with given username Already exists during SignUp
|
||||||
|
function isAlreadyExist ()
|
||||||
|
{
|
||||||
|
$query = "SELECT * FROM " . $this->table_name . " WHERE BINARY username='".$this->username."'";
|
||||||
|
// prepare query statement
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
return ($stmt->rowCount() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
//Update user infos
|
||||||
|
function update ()
|
||||||
|
{
|
||||||
|
$query = "UPDATE " . $this->table_name . "
|
||||||
|
SET firstName=:firstName, lastName=:lastName, leftHanded=:leftHanded, size=:size, maleGender=:maleGender, charSkinAssetName=:charSkinAssetName, " . "
|
||||||
|
weaponAssetName=:weaponAssetName, lastConnection=:lastConnection " . "
|
||||||
|
WHERE id=".$this->id;
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// sanitize
|
||||||
|
$this->sanitize_update();
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
//$stmt->bindParam(":username", $this->username);
|
||||||
|
//$stmt->bindParam(":password", $this->password);
|
||||||
|
//$stmt->bindParam(":avgPrecision", $this->avgPrecision);
|
||||||
|
//$stmt->bindParam(":avgReaction", $this->avgReaction);
|
||||||
|
//$stmt->bindParam(":avgFault", $this->avgFault);
|
||||||
|
//$stmt->bindParam(":avgRapidity", $this->avgRapidity);
|
||||||
|
$stmt->bindParam(":firstName", $this->firstName);
|
||||||
|
$stmt->bindParam(":lastName", $this->lastName);
|
||||||
|
$stmt->bindParam(":leftHanded", $this->leftHanded);
|
||||||
|
$stmt->bindParam(":maleGender", $this->maleGender);
|
||||||
|
$stmt->bindParam(":charSkinAssetName", $this->charSkinAssetName);
|
||||||
|
$stmt->bindParam(":weaponAssetName", $this->weaponAssetName);
|
||||||
|
$stmt->bindParam(":lastConnection", $this->lastConnection);
|
||||||
|
$stmt->bindParam(":size", $this->size);
|
||||||
|
// execute query
|
||||||
|
return $stmt->execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
//user reset password methods
|
||||||
|
function resetPassword ()
|
||||||
|
{
|
||||||
|
if ($this->id < 1)
|
||||||
|
{
|
||||||
|
// find userId for this username
|
||||||
|
$query = "SELECT id FROM " . $this->table_name . " WHERE BINARY username='".$this->username."'";
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
if ($stmt->rowCount() != 1)
|
||||||
|
{
|
||||||
|
// no user found for this username (or multiple users, but this should not happen)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// get retrieved row
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
// retrieve user values
|
||||||
|
$this->id = (int)$row['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// now that we have an id, run the update method
|
||||||
|
$query = "UPDATE " . $this->table_name . " SET password=:password WHERE id=".$this->id;
|
||||||
|
|
||||||
|
// prepare query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
|
||||||
|
// sanitize
|
||||||
|
$this->password=htmlspecialchars(strip_tags($this->password));
|
||||||
|
|
||||||
|
// bind values
|
||||||
|
$stmt->bindParam(":password", $this->password);
|
||||||
|
|
||||||
|
// execute query
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
// load user after password update
|
||||||
|
return $this->load();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/db_event_type.php';
|
||||||
|
|
||||||
|
class UserRole extends DBObjectType
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
protected $table_name = USERROLES_TABLE_NAME;
|
||||||
|
//protected $array_key = "type";
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/db_event_type.php';
|
||||||
|
|
||||||
|
class SessionType extends DBObjectType
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//private $conn;
|
||||||
|
protected $table_name = USERSTATUS_TABLE_NAME;
|
||||||
|
protected $array_key = "status";
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
class SessionDebriefRow
|
||||||
|
{
|
||||||
|
public $sessionId = -1;
|
||||||
|
public int $sessionTypeId = -1;
|
||||||
|
public $sessionType = "";
|
||||||
|
public $sessionName = "";
|
||||||
|
public $sessionDate = "";
|
||||||
|
public $mapName = "";
|
||||||
|
public $scenarioName = "";
|
||||||
|
public bool $sessionSuccessful = false;
|
||||||
|
public float $sessionDuration = 0.0;
|
||||||
|
public int $triggerTypeId = -1;
|
||||||
|
public $triggerType = "";
|
||||||
|
public int $shooterId = -1;
|
||||||
|
public $shooterName = "";
|
||||||
|
public int $shooterRoleId = -1;
|
||||||
|
public $shooterRole = "";
|
||||||
|
public int $shotIndex = -1;
|
||||||
|
public int $reactId = -1;
|
||||||
|
public int $reactModeId = -1;
|
||||||
|
public $reactMode = "";
|
||||||
|
public int $reactTypeId = -1;
|
||||||
|
public $reactType = "";
|
||||||
|
public int $targetUserId = -1;
|
||||||
|
public $targetUserName = "";
|
||||||
|
public int $targetRoleId = -1;
|
||||||
|
public $targetRole = "";
|
||||||
|
public $targetName = "";
|
||||||
|
public $targetBoneName = "";
|
||||||
|
public bool $targetKilled = false;
|
||||||
|
public float $hitLocationX = 0.0;
|
||||||
|
public float $hitLocationY = 0.0;
|
||||||
|
public $hitLocationTag = "";
|
||||||
|
public float $hitPrecision = 0.0;
|
||||||
|
public float $hitTargetDistance = 0.0;
|
||||||
|
public float $reactTime = 0.0;
|
||||||
|
public float $timeStamp = 0.0;
|
||||||
|
public int $nbHit = 0;
|
||||||
|
public int $nbKilled = 0;
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public static function withRow (array $row)
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
$instance->readRow($row);
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function readRow (array $row)
|
||||||
|
{
|
||||||
|
$this->sessionId = $row['SessionId'];
|
||||||
|
$this->sessionTypeId = $row['SessionTypeId'];
|
||||||
|
$this->sessionType = $row['SessionType'];
|
||||||
|
$this->sessionName = $row['SessionName'];
|
||||||
|
$this->sessionDate = $row['SessionDate'];
|
||||||
|
$this->mapName = $row['MapName'];
|
||||||
|
$this->scenarioName = $row['ScenarioName'];
|
||||||
|
$this->sessionSuccessful = $row['SessionSuccessful'] == 1;
|
||||||
|
$this->sessionDuration = (float)$row['SessionDuration'];
|
||||||
|
$this->triggerTypeId = (int)$row['TriggerTypeId'];
|
||||||
|
$this->triggerType = $row['TriggerType'];
|
||||||
|
$this->shooterId = (int)$row['ShooterId'];
|
||||||
|
$this->shooterName = $row['ShooterName'];
|
||||||
|
$this->shooterRoleId = (int)$row['ShooterRoleId'];
|
||||||
|
$this->shooterRole = $row['ShooterRole'];
|
||||||
|
$this->shotIndex = (int)$row['ShotIndex'];
|
||||||
|
$this->reactId = (int)$row['ReactId'];
|
||||||
|
$this->reactModeId = (int)$row['ReactModeId'];
|
||||||
|
$this->reactMode = $row['ReactMode'];
|
||||||
|
$this->reactTypeId = (int)$row['ReactTypeId'];
|
||||||
|
$this->reactType = $row['ReactType'];
|
||||||
|
$this->targetUserId = (int)$row['TargetUserId'];
|
||||||
|
$this->targetUserName = $row['TargetUserName'];
|
||||||
|
$this->targetRoleId = (int)$row['TargetRoleId'];
|
||||||
|
$this->targetRole = $row['TargetRole'];
|
||||||
|
$this->targetName = $row['TargetName'];
|
||||||
|
$this->targetBoneName = $row['TargetBoneName'];
|
||||||
|
$this->targetKilled = $row['TargetKilled'] == 1;
|
||||||
|
$this->hitLocationX = (float)$row['HitLocationX'];
|
||||||
|
$this->hitLocationY = (float)$row['HitLocationY'];
|
||||||
|
$this->hitLocationTag = $row['HitLocationTag'];
|
||||||
|
$this->hitPrecision = (float)$row['HitPrecision'];
|
||||||
|
$this->hitTargetDistance = (float)$row['HitTargetDistance'];
|
||||||
|
$this->reactTime = (float)$row['ReactionTime'];
|
||||||
|
$this->timeStamp = (float)$row['TimeStamp'];
|
||||||
|
$this->nbHit = (int)$row['NbHit'];
|
||||||
|
$this->nbKilled = (int)$row['NbKilled'];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"SessionId" => (int)$this->sessionId,
|
||||||
|
"SessionTypeId" => (int)$this->sessionTypeId,
|
||||||
|
"SessionTypeAsString" => $this->sessionType,
|
||||||
|
"SessionName" => $this->sessionName,
|
||||||
|
"SessionDateAsString" => $this->sessionDate,
|
||||||
|
"MapName" => $this->mapName,
|
||||||
|
"ScenarioName" => $this->scenarioName,
|
||||||
|
"SessionSuccessful" => $this->sessionSuccessful,
|
||||||
|
"SessionDuration" => (float)$this->sessionDuration,
|
||||||
|
"TriggerTypeId" => (int)$this->triggerTypeId,
|
||||||
|
"TriggerTypeAsString" => $this->triggerType,
|
||||||
|
"ShooterId" => (int)$this->shooterId,
|
||||||
|
"ShooterName" => $this->shooterName,
|
||||||
|
"ShooterRoleId" => (int)$this->shooterRoleId,
|
||||||
|
"ShooterRoleAsString" => $this->shooterRole,
|
||||||
|
"ShotIndex" => (int)$this->shotIndex,
|
||||||
|
"ReactId" => (int)$this->reactId,
|
||||||
|
"ReactModeId" => (int)$this->reactModeId,
|
||||||
|
"ReactModeAsString" => $this->reactMode,
|
||||||
|
"ReactTypeId" => (int)$this->reactTypeId,
|
||||||
|
"ReactTypeAsString" => $this->reactType,
|
||||||
|
"TargetUserId" => (int)$this->targetUserId,
|
||||||
|
"TargetUserName" => $this->targetUserName,
|
||||||
|
"TargetRoleId" => (int)$this->targetRoleId,
|
||||||
|
"TargetRoleAsString" => $this->targetRole,
|
||||||
|
"TargetName" => $this->targetName,
|
||||||
|
"TargetBoneName" => $this->targetBoneName,
|
||||||
|
"TargetKilled" => $this->targetKilled,
|
||||||
|
"HitLocationX" => (float)$this->hitLocationX,
|
||||||
|
"HitLocationY" => (float)$this->hitLocationY,
|
||||||
|
"HitLocationTag" => $this->hitLocationTag,
|
||||||
|
"HitPrecision" => (float)$this->hitPrecision,
|
||||||
|
"HitTargetDistance" => (float)$this->hitTargetDistance,
|
||||||
|
"ReactionTime" => (float)$this->reactTime,
|
||||||
|
"TimeStamp" => (float)$this->timeStamp,
|
||||||
|
"NbHit" => (int)$this->nbHit,
|
||||||
|
"NbKilled" => (int)$this->nbKilled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SessionDebriefRowWithTotals
|
||||||
|
{
|
||||||
|
public SessionDebriefRow $sessionRow;
|
||||||
|
public int $nbFiredShotsByUser = 0;
|
||||||
|
public int $nbEnemyHitsByUser = 0;
|
||||||
|
public int $nbCivilHitsByUser = 0;
|
||||||
|
public int $nbPoliceHitsByUser = 0;
|
||||||
|
public int $nbMissedShotsByUser = 0;
|
||||||
|
//public int $nbFiredShotsByIA = 0;
|
||||||
|
public int $nbEnemyHitsByIA = 0;
|
||||||
|
public int $nbCivilHitsByIA = 0;
|
||||||
|
public int $nbPoliceHitsByIA = 0;
|
||||||
|
//public int $nbMissedShotsByIA = 0;
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public static function withRow (array $row)
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
$instance->readRow($row);
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function readRow (array $row)
|
||||||
|
{
|
||||||
|
$this->sessionRow = SessionDebriefRow::withRow($row);
|
||||||
|
|
||||||
|
if ($row['ReactTypeId'] == -1)
|
||||||
|
$this->nbMissedShotsByUser = (int)$row['NbShotsOfType'];
|
||||||
|
else if ($row['ShooterRoleId'] == 0)
|
||||||
|
{
|
||||||
|
$this->nbFiredShotsByUser = (int)$row['NbShotsOfType'];
|
||||||
|
switch ($row['ReactTypeId'])
|
||||||
|
{
|
||||||
|
case 0 : $this->nbEnemyHitsByUser = (int)$row['NbShotsOfType']; break;
|
||||||
|
case 1 : $this->nbCivilHitsByUser = (int)$row['NbShotsOfType']; break;
|
||||||
|
case 2 : $this->nbPoliceHitsByUser = (int)$row['NbShotsOfType']; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if ($row['ShooterRoleId'] == 3) // IA
|
||||||
|
{
|
||||||
|
//$this->nbFiredShotsByIA = (int)$row['NbShotsOfType'];
|
||||||
|
switch ($row['ReactTypeId'])
|
||||||
|
{
|
||||||
|
case 0 : $this->nbEnemyHitsByIA = (int)$row['NbShotsOfType']; break;
|
||||||
|
case 1 : $this->nbCivilHitsByIA = (int)$row['NbShotsOfType']; break;
|
||||||
|
case 2 : $this->nbPoliceHitsByIA = (int)$row['NbShotsOfType']; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"session" => $this->sessionRow->toArray(),
|
||||||
|
"NbFiredShotsByUser" => (int)$this->nbFiredShotsByUser,
|
||||||
|
"NbEnemyHitsByUser" => (int)$this->nbEnemyHitsByUser,
|
||||||
|
"NbCivilHitsByUser" => (int)$this->nbCivilHitsByUser,
|
||||||
|
"NbPoliceHitsByUser" => (int)$this->nbPoliceHitsByUser,
|
||||||
|
"NbMissedShotsByUser" => (int)$this->nbMissedShotsByUser,
|
||||||
|
//"NbFiredShotsByIA" => (int)$this->nbFiredShotsByIA,
|
||||||
|
"NbEnemyHitsByIA" => (int)$this->nbEnemyHitsByIA,
|
||||||
|
"NbCivilHitsByIA" => (int)$this->nbCivilHitsByIA,
|
||||||
|
"NbPoliceHitsByIA" => (int)$this->nbPoliceHitsByIA
|
||||||
|
//"NbMissedShotsByIA" => (int)$this->nbMissedShotsByIA
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
747
version/proserve-ReleaseTemplate/_api/objects/stats_object.php
Normal file
747
version/proserve-ReleaseTemplate/_api/objects/stats_object.php
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
<?php
|
||||||
|
//include_once "../objects/db_table_object.php";
|
||||||
|
include_once '../objects/db_session.php';
|
||||||
|
include_once '../objects/db_user.php';
|
||||||
|
include_once '../objects/db_view_sessiondebrief_row.php';
|
||||||
|
include_once '../objects/stats_user_globals.php';
|
||||||
|
//include_once '../objects/stats_user_in_session_row.php';
|
||||||
|
|
||||||
|
class StatsObject extends DBTableObject
|
||||||
|
{
|
||||||
|
// database connection and table name
|
||||||
|
//protected $conn;
|
||||||
|
protected $array_key = "stats";
|
||||||
|
protected $elements = array();
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
protected $excludeCalibrationSessionsFromStats = true;
|
||||||
|
protected function getCalibrationSessionsConstraint ($sessionsTableIndex="SD") : string
|
||||||
|
{
|
||||||
|
return $this->excludeCalibrationSessionsFromStats == true ? " AND " . $sessionsTableIndex . ".ScenarioName NOT LIKE '%Calibration%'" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
protected $minimumSessionDuration = 10;
|
||||||
|
protected function getSessionDurationConstraint ($sessionsTableIndex="SD", $allowZero=false) : string
|
||||||
|
{
|
||||||
|
return $this->minimumSessionDuration > 0 ? " AND (" . $sessionsTableIndex . ".timeToFinish > " . $this->minimumSessionDuration . ($allowZero ? " OR " . $sessionsTableIndex . ".timeToFinish = 0" : "") . ")" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
protected function getSessionsWithUserConstraint ($userId) : string
|
||||||
|
{
|
||||||
|
return $userId > 0 ? "SELECT DISTINCT UP.SessionId FROM " . PARTICIPATES_TABLE_NAME . " UP WHERE UP.userId=" . $userId : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return $this->elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// get users who took place in given session
|
||||||
|
public function fixDurations ()
|
||||||
|
{
|
||||||
|
$query = "SELECT S.id AS SessionId FROM " . SESSIONS_TABLE_NAME . " S, " . TRIGGEREVENTS_TABLE_NAME . " TE WHERE S.timeToFinish=0 AND S.id=TE.sessionId";
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
$nb = 0;
|
||||||
|
|
||||||
|
// get max time for trigger events in these sessions
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
$subQuery = "SELECT COALESCE(MAX(TE.timeStamp),0) AS LastTriggerEvent FROM " . TRIGGEREVENTS_TABLE_NAME . " TE WHERE TE.sessionId=" . $row['SessionId'];
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$subStmt = $this->conn->prepare($subQuery);
|
||||||
|
$subStmt->execute();
|
||||||
|
|
||||||
|
while ($subRow = $subStmt->fetch(PDO::FETCH_ASSOC)) // should be only 1 row
|
||||||
|
{
|
||||||
|
if ($subRow['LastTriggerEvent'] != 0) // should always be true
|
||||||
|
{
|
||||||
|
$subQuery = "UPDATE " . SESSIONS_TABLE_NAME . " SET timeToFinish=" . $subRow['LastTriggerEvent'] . " WHERE id=" . $row['SessionId'];
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$subStmt = $this->conn->prepare($subQuery);
|
||||||
|
$subStmt->execute();
|
||||||
|
|
||||||
|
$nb++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $nb;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// get users who took place in given session
|
||||||
|
public function getRawUsers ($sessionId)
|
||||||
|
{
|
||||||
|
// Check if we are looking for a specific session
|
||||||
|
$sessionConstraint = $sessionId > 0 ? " AND P.sessionId=" . $sessionId : "";
|
||||||
|
|
||||||
|
$query = "SELECT DISTINCT U.* FROM " . USERS_TABLE_NAME . " U LEFT JOIN " . PARTICIPATES_TABLE_NAME . " P ON U.id = P.userId " .
|
||||||
|
"LEFT JOIN " . SESSIONS_TABLE_NAME . " SD ON P.sessionId=SD.id " .
|
||||||
|
"WHERE U.id > 0" . $sessionConstraint . $this->getSessionDurationConstraint("SD");
|
||||||
|
//$query = "SELECT DISTINCT U.* FROM " . USERS_TABLE_NAME . " U, " . PARTICIPATES_TABLE_NAME . " P WHERE U.id = P.userId AND U.id > 0" . $sessionConstraint;
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// get users who took place in given session
|
||||||
|
public function getUsersInSession ($sessionId)
|
||||||
|
{
|
||||||
|
// Check if we are looking for a specific session
|
||||||
|
$stmt = $this->getRawUsers($sessionId);
|
||||||
|
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
//$sess = new GameSession($this->conn);
|
||||||
|
//$sess->readRow($row);
|
||||||
|
$user = User::withRow($this->conn, $row);
|
||||||
|
array_push( $this->elements, $user->toArray() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// get sessions of given type that given user has taken part in
|
||||||
|
public function getRawSessions ($userId=-1, $typeId=-1)
|
||||||
|
{
|
||||||
|
// Check if we are looking for a specific user and/or type of session
|
||||||
|
$userConstraint = $userId > 0 ? " AND P.userId=" . $userId : "";
|
||||||
|
$typeConstraint = $typeId >= 0 ? " AND SD.sessionType=" . $typeId : "";
|
||||||
|
|
||||||
|
// Order results from newest to oldest session
|
||||||
|
$orderConstraint = " ORDER BY SD.id DESC";
|
||||||
|
|
||||||
|
//$query = "SELECT DISTINCT S.* FROM " . SESSIONS_TABLE_NAME . " S LEFT JOIN " . PARTICIPATES_TABLE_NAME . " P ON S.id = P.sessionId WHERE 1 " .
|
||||||
|
$query = "SELECT DISTINCT SD.* FROM " . SESSIONS_TABLE_NAME . " SD, " . PARTICIPATES_TABLE_NAME . " P WHERE SD.id = P.sessionId " .
|
||||||
|
$this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $userConstraint . $typeConstraint . $orderConstraint;
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// get sessions that given user has taken part in
|
||||||
|
public function getSessionsForUser ($userId, $typeId=-1)
|
||||||
|
{
|
||||||
|
// Check if we are looking for a specific user and/or session
|
||||||
|
$stmt = $this->getRawSessions($userId, $typeId);
|
||||||
|
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
//$sess = new GameSession($this->conn);
|
||||||
|
//$sess->readRow($row);
|
||||||
|
$sess = GameSession::withRow($this->conn, $row);
|
||||||
|
array_push( $this->elements, $sess->toArray() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// debrief mission : get stats for given session
|
||||||
|
public function getRawStatsForSession ($sessionId, $userId, $fromUser=-1)
|
||||||
|
{
|
||||||
|
$sessionStats = array();
|
||||||
|
|
||||||
|
// first create expected rows for results, so that every user will get stats (even if user didn't fire or received any shot)
|
||||||
|
if ($sessionId > 0 && $userId > 0)
|
||||||
|
{
|
||||||
|
// we want stats for a specific user in a specific session
|
||||||
|
// => we only need to create one default row, just in case user didn't fire or received any shot
|
||||||
|
$defaultRow = new UserStatsInSessionRow();
|
||||||
|
$defaultRow->createSessionAndUser($this->conn, $sessionId, $userId);
|
||||||
|
array_push( $sessionStats, $defaultRow );
|
||||||
|
}
|
||||||
|
else if ($userId == -1)
|
||||||
|
{
|
||||||
|
// we want stats for all users in a specific session
|
||||||
|
// => we need to create one default row for each user
|
||||||
|
$stmt_usersInSession = $this->getRawUsers($sessionId);
|
||||||
|
while ($row = $stmt_usersInSession->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
$defaultRow = new UserStatsInSessionRow();
|
||||||
|
$defaultRow->createSessionAndUser($this->conn, $sessionId, $row['id']);
|
||||||
|
array_push( $sessionStats, $defaultRow );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if ($sessionId == -1)
|
||||||
|
{
|
||||||
|
// we want stats for a specific user in all sessions he took place in
|
||||||
|
// => we need to create one default row for each user
|
||||||
|
$stmt_sessionsForUser = $this->getRawSessions($userId);
|
||||||
|
while ($row = $stmt_sessionsForUser->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
$defaultRow = new UserStatsInSessionRow();
|
||||||
|
$defaultRow->createSessionAndUser($this->conn, $row['id'], $userId);
|
||||||
|
array_push( $sessionStats, $defaultRow );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// both $sessionId and $userId are set to -1
|
||||||
|
// => do nothing, this will (should) not happen
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Get fired shots
|
||||||
|
$stmt_firedShots = $this->getShotsFiredByUser($sessionId, $userId, $fromUser);
|
||||||
|
while ($row = $stmt_firedShots->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
// Only create stats row for shooters with id > 0
|
||||||
|
if ((int)$row['UserId'] > 0)
|
||||||
|
{
|
||||||
|
$statsRow = $this->findStats( $sessionStats, (int)$row['UserId'], (int)$row['SessionId'] );
|
||||||
|
if ($statsRow == null)
|
||||||
|
{
|
||||||
|
$statsRow = UserStatsInSessionRow::withRow($this->conn, $row);
|
||||||
|
$statsRow->setFiredShots($row);
|
||||||
|
array_push( $sessionStats, $statsRow );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
$statsRow->setFiredShots($row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get received shots
|
||||||
|
$stmt_receivedShots = $this->getReceivedHitsForUser($sessionId, $userId, $fromUser);
|
||||||
|
while ($row = $stmt_receivedShots->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
// Only create stats row for hit users with id > 0
|
||||||
|
if ((int)$row['UserId'] > 0)
|
||||||
|
{
|
||||||
|
$statsRow = $this->findStats ( $sessionStats, (int)$row['UserId'], (int)$row['SessionId'] );
|
||||||
|
if ($statsRow == null)
|
||||||
|
{
|
||||||
|
$statsRow = UserStatsInSessionRow::withRow($this->conn, $row);
|
||||||
|
$statsRow->setReceivedHits($row);
|
||||||
|
array_push( $sessionStats, $statsRow );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
$statsRow->setReceivedHits($row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get average precision and reaction time
|
||||||
|
$stmt_averages = $this->getPrecisionAndReactionTimeForUser($sessionId, $userId, $fromUser);
|
||||||
|
while ($row = $stmt_averages->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
// Only create stats row for hit users with id > 0
|
||||||
|
if ((int)$row['UserId'] > 0)
|
||||||
|
{
|
||||||
|
$statsRow = $this->findStats ( $sessionStats, (int)$row['UserId'], (int)$row['SessionId'] );
|
||||||
|
if ($statsRow == null)
|
||||||
|
{
|
||||||
|
$statsRow = UserStatsInSessionRow::withRow($this->conn, $row);
|
||||||
|
$statsRow->setPrecisionAndReactionTime($row);
|
||||||
|
array_push( $sessionStats, $statsRow );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
$statsRow->setPrecisionAndReactionTime($row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total of targets killed in the session
|
||||||
|
$stmt_targetKilled = $this->getTargetKilledInSession($sessionId);
|
||||||
|
while ($row = $stmt_targetKilled->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
// Add totals stats to all rows of the same session
|
||||||
|
foreach ($sessionStats as $statsRow)
|
||||||
|
{
|
||||||
|
if ($statsRow->sessionId == (int)$row['SessionId'])
|
||||||
|
$statsRow->setTotalKills($row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sessionStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// debrief mission : get stats for given session
|
||||||
|
public function getStatsForSession ($sessionId, $userId, $fromUser=-1)
|
||||||
|
{
|
||||||
|
$sessionStats = $this->getRawStatsForSession($sessionId, $userId, $fromUser);
|
||||||
|
|
||||||
|
// Parse workingStats array to output results
|
||||||
|
foreach ($sessionStats as $outputStats)
|
||||||
|
array_push( $this->elements, $outputStats->toArray() );
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// find a row of stats for given user and stats
|
||||||
|
function findStats (array $statsArray, int $userId, int $sessionId)
|
||||||
|
{
|
||||||
|
foreach ($statsArray as $row)
|
||||||
|
{
|
||||||
|
if (call_user_func_array(array($row, 'isForUserInSession'), array($userId, $sessionId)) === true)
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// get stats : main call for getting stats from Unreal
|
||||||
|
public function get ($sessionId, $userId, $sessionType, $fromUserId=-1)
|
||||||
|
{
|
||||||
|
if ($sessionType == 0 || $sessionType == 1 || $sessionType == 7) // Firerange, Challenge or Long Range
|
||||||
|
$this->getResultsForSession ($sessionId, $userId, $fromUserId);
|
||||||
|
else if ($sessionId > 0 || $userId > 0)
|
||||||
|
$this->getStatsForSession ($sessionId, $userId, $fromUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// user history : get user "all-time" totals
|
||||||
|
public function getUserHistory ($userId, $sessionId, $quickMode)
|
||||||
|
{
|
||||||
|
$userGlobals = new UserGlobalStats();
|
||||||
|
|
||||||
|
if ($quickMode == 1 && $userId > 0)
|
||||||
|
{
|
||||||
|
$userGlobals->totals->createSessionAndUser($this->conn, $sessionId, $userId);
|
||||||
|
$userGlobals->totals->averagePrecision = $userGlobals->totals->user->avgPrecision;
|
||||||
|
$userGlobals->totals->averageReactionTime = $userGlobals->totals->user->avgReaction;
|
||||||
|
|
||||||
|
$userSubConstraint = $userId >= 0 ? " AND P.UserId = " . $userId : ""; // always true
|
||||||
|
|
||||||
|
// Calculate date of first and last sessions
|
||||||
|
$query = "SELECT P.userID AS UserId, MIN(SD.SessionDate) AS MinDate, MAX(SD.SessionDate) AS MaxDate, " . "
|
||||||
|
COUNT(DISTINCT P.UserId, P.sessionId) AS NbSessions, SUM(SD.timeToFinish) AS TotalDuration " . "
|
||||||
|
FROM " . PARTICIPATES_TABLE_NAME . " P, " . SESSIONS_TABLE_NAME . " SD WHERE SD.id=P.sessionId " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $userSubConstraint . " GROUP BY P.UserId";
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) // should be only 1 row
|
||||||
|
{
|
||||||
|
$userGlobals->setSessionTotals($row);
|
||||||
|
//$userGlobals->nbSessions = $row['NbSessions'];
|
||||||
|
//$userGlobals->totalDuration = $row['TotalDuration'];
|
||||||
|
//$userGlobals->firstSession = $row['MinDate'];
|
||||||
|
//$userGlobals->lastSession = $row['MaxDate'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate shots fired
|
||||||
|
$userConstraint = $userId > 0 ? " AND SDTE.srcUserId=" . $userId : "";
|
||||||
|
$query = "SELECT COALESCE(SDTE.srcUserId,-1) AS UserId,
|
||||||
|
COALESCE(X.NbShotsFired,0) AS NbShotsFired,
|
||||||
|
COALESCE(X.NbShotsFired,0) - COALESCE(SUM(CASE WHEN Y.ReactTypeId>=0 AND Y.ReactTypeId<6 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END),0) AS NbMissedShots,
|
||||||
|
SUM(CASE WHEN Y.ReactTypeId=0 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbEnemyHits,
|
||||||
|
SUM(CASE WHEN Y.ReactTypeId=1 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbCivilHits,
|
||||||
|
SUM(CASE WHEN Y.ReactTypeId=2 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbPoliceHits,
|
||||||
|
SUM(CASE WHEN (Y.ReactTypeId=4 OR Y.ReactTypeId=5) AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbObjectHits,
|
||||||
|
SUM(CASE WHEN Y.ReactTypeId=6 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbDeadBodyHits
|
||||||
|
FROM " . SESSIONS_TABLE_NAME . " SD LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " SDTE ON (SDTE.sessionId=SD.id)
|
||||||
|
LEFT JOIN " . REACTEVENTS_TABLE_NAME . " SDRE ON (SDTE.sessionId=SDRE.srcEventSessionId AND SDTE.indexCount=SDRE.srcEventIndex)
|
||||||
|
LEFT JOIN " . "
|
||||||
|
(SELECT TE.srcUserId AS ShooterId, COUNT(DISTINCT TE.sessionId, TE.indexCount) AS NbShotsFired FROM " . TRIGGEREVENTS_TABLE_NAME . " TE GROUP BY TE.srcUserId) AS X
|
||||||
|
ON X.ShooterId=SDTE.srcUserId
|
||||||
|
LEFT JOIN " . "
|
||||||
|
(SELECT TE.srcUserId AS ShooterId, RE.ReactType AS ReactTypeId, COUNT(DISTINCT TE.sessionId, TE.indexCount, RE.hitTargetName) AS NbHits, RE.id AS ReactId, RE.hitPrecision AS ReactPrecision
|
||||||
|
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE
|
||||||
|
WHERE TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 GROUP BY ShooterId, ReactTypeId, ReactID) AS Y
|
||||||
|
ON (Y.ShooterId=SDTE.srcUserId AND Y.ReactId=SDRE.id)
|
||||||
|
LEFT JOIN " . "
|
||||||
|
(SELECT TE1.srcUserId AS ShooterId, COALESCE(COUNT(DISTINCT TE1.sessionId, TE1.indexCount),0) AS NbMissedShots FROM " . TRIGGEREVENTS_TABLE_NAME . " TE1, " . REACTEVENTS_TABLE_NAME . " RE1
|
||||||
|
WHERE TE1.sessionId=RE1.srcEventSessionId AND TE1.indexCount NOT IN (SELECT RE2.srcEventIndex FROM " . REACTEVENTS_TABLE_NAME . " RE2 WHERE RE2.srcEventSessionId=RE1.srcEventSessionId)) AS Z
|
||||||
|
ON (Z.ShooterId=SDTE.srcUserId) " . "
|
||||||
|
WHERE 1 " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $userConstraint . " GROUP BY SDTE.srcUserId";
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
$userGlobals->totals->setFiredShots($row);
|
||||||
|
//$userGlobals->totals->nbFiredShotsByUser = $row['NbShotsFired'];
|
||||||
|
//$userGlobals->totals->nbMissedShotsByUser = $row['NbMissedShots'];
|
||||||
|
//$userGlobals->totals->nbEnemyHitsByUser = $row['NbEnemyHits'];
|
||||||
|
//$userGlobals->totals->nbCivilHitsByUser = $row['NbCivilHits'];
|
||||||
|
//$userGlobals->totals->nbPoliceHitsByUser = $row['NbPoliceHits'];
|
||||||
|
//$userGlobals->totals->nbObjectHitsByUser = $row['NbObjectHits'];
|
||||||
|
//$userGlobals->totals->nbDeadBodyHitsByUser = $row['NbDeadBodyHits'];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Calculate received hits
|
||||||
|
$userConstraint = $userId > 0 ? " AND X.TargetUserId=" . $userId : "";
|
||||||
|
$query = "SELECT COALESCE(X.TargetUserId, -1) AS UserId,
|
||||||
|
SUM(CASE WHEN X.ShooterRoleId=3 THEN X.NbHits ELSE 0 END) AS NbEnemyShotsIA,
|
||||||
|
SUM(CASE WHEN X.ShooterRoleId=1 THEN X.NbHits ELSE 0 END) AS NbEnemyShotsUser,
|
||||||
|
SUM(CASE WHEN X.ShooterRoleId=0 THEN X.NbHits ELSE 0 END) AS NbPoliceHits
|
||||||
|
FROM (SELECT TE.SessionId AS SessionId, RE.hitUserId AS TargetUserId,
|
||||||
|
(SELECT IFNULL((SELECT P.role FROM " . PARTICIPATES_TABLE_NAME . " P WHERE P.sessionId=TE.sessionId AND P.userId=TE.srcUserId),3)) AS ShooterRoleId,
|
||||||
|
COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS NbHits
|
||||||
|
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE, " . SESSIONS_TABLE_NAME . " SD " .
|
||||||
|
"WHERE SD.id=TE.sessionId AND TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 " .
|
||||||
|
$this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") .
|
||||||
|
"GROUP BY SessionId, TargetUserId, ShooterRoleId) AS X
|
||||||
|
WHERE 1 " . $userConstraint . " GROUP BY X.TargetUserId";
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
$userGlobals->totals->setReceivedHits($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate totals hits by type
|
||||||
|
$sessionConstraint = $sessionId > 0 ? " AND SDTE.SessionId = " . $sessionId : "";
|
||||||
|
$userConstraint = $userId > 0 ? " AND SDTE.SessionId IN (" . $this->getSessionsWithUserConstraint($userId) . ")" : "";
|
||||||
|
|
||||||
|
$query = "SELECT SUM(CASE WHEN X.ReactTypeId=0 THEN X.NbHits ELSE 0 END) AS NbEnemyKilled,
|
||||||
|
SUM(CASE WHEN X.ReactTypeId=1 THEN X.NbHits ELSE 0 END) AS NbCivilKilled,
|
||||||
|
SUM(CASE WHEN X.ReactTypeId=2 THEN X.NbHits ELSE 0 END) AS NbPoliceKilled
|
||||||
|
FROM " . SESSIONS_TABLE_NAME . " SD LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " SDTE ON (SD.id = SDTE.sessionId)
|
||||||
|
LEFT JOIN
|
||||||
|
(SELECT TE.SessionId AS SessionId, RE.hitUserId AS TargetUserId, TE.srcUserId AS ShooterRoleId, COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS NbHits, RE.srcEventIndex AS TriggerIndex, RE.ReactType AS ReactTypeId
|
||||||
|
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 AND RE.TargetKilled=1
|
||||||
|
GROUP BY SessionId, TargetUserId, ShooterRoleId, ReactTypeId) AS X
|
||||||
|
ON (X.SessionId=SDTE.SessionId AND X.TriggerIndex=SDTE.indexCount)
|
||||||
|
WHERE 1 " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $userConstraint;
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
$userGlobals->totals->setTotalKills($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
$query = "SELECT X.*, " .
|
||||||
|
"SUM(X.HitPrecision)/COALESCE(COUNT(DISTINCT X.SessionId, X.ShotIndex),1) AS AvgPrecision, " .
|
||||||
|
"SUM(X.ReactionTime)/COALESCE(SUM(CASE WHEN X.ReactionTime > 0 THEN CEILING(X.HitPrecision) ELSE 0 END),1) AS AvgReactTime " .
|
||||||
|
"FROM " .
|
||||||
|
"(SELECT TE.sessionId AS SessionId, S.sessionType AS SessionTypeId, ST.displayName AS SessionType, '' AS SessionName, '' AS SessionDate, '' AS MapName, S.scenarioName AS ScenarioName, " .
|
||||||
|
"0 AS SessionSuccessful, 0 AS SessionDuration, 0 AS TriggerTypeId, 'Fire' AS TriggerType, TE.srcUserId AS ShooterId, '' AS ShooterName, -1 AS ShooterRoleId, '' AS ShooterRole, " .
|
||||||
|
"TE.indexCount AS ShotIndex, RE.id AS ReactId, -1 AS ReactModeId, '' AS ReactMode, -1 AS ReactTypeId, '' AS ReactType, RE.hitUserId AS TargetUserId, " .
|
||||||
|
"'' AS TargetUserName, -1 AS TargetRoleId, '' AS TargetRole, RE.hitTargetName AS TargetName, RE.hitBoneName AS TargetBoneName, 0 AS TargetKilled, 0 AS HitLocationX, 0 AS HitLocationY, " .
|
||||||
|
"'' AS HitLocationTag, RE.hitPrecision AS HitPrecision, 0 AS HitTargetDistance, RE.reactTime as ReactionTime, 0 AS TimeStamp, 0 AS NbHit, 0 AS NbKilled " .
|
||||||
|
"FROM " . SESSIONS_TABLE_NAME . " S " .
|
||||||
|
"LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " TE ON (S.id=TE.sessionId) " .
|
||||||
|
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " RE ON (TE.sessionId=RE.srcEventSessionId AND TE.indexCount=RE.srcEventIndex) " .
|
||||||
|
"LEFT JOIN " . SESSIONTYPES_TABLE_NAME . " ST ON (S.sessionType=ST.id) " .
|
||||||
|
"WHERE 1 " . $this->getSessionDurationConstraint("S") . $this->getCalibrationSessionsConstraint("S") .
|
||||||
|
" GROUP BY ShooterId, SessionId, TE.indexCount) AS X " .
|
||||||
|
"WHERE X.ShooterId = " . $userId . " AND X.SessionId IN (" . $this->getSessionsWithUserConstraint($userId) . ") GROUP BY X.ShooterId, X.SessionId ORDER BY X.SessionId ASC";
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
$row['HitPrecision'] = $row['AvgPrecision'];
|
||||||
|
$row['ReactionTime'] = $row['AvgReactTime'];
|
||||||
|
|
||||||
|
$stats = SessionDebriefRow::withRow($row);
|
||||||
|
array_push( $userGlobals->sessionDebriefRows, $stats->toArray() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// create SQL constraints for query
|
||||||
|
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId = " . $sessionId : "";
|
||||||
|
$userConstraint = $userId >= 0 ? " AND SD.ShooterId = " . $userId : "";
|
||||||
|
$userSubConstraint = $userId >= 0 ? " AND P.UserId = " . $userId : "";
|
||||||
|
|
||||||
|
// get raw results from query
|
||||||
|
$sessionStats = $this->getRawStatsForSession ($sessionId, $userId, -1);
|
||||||
|
|
||||||
|
$newNbFiredShotsForReactionTime = 0;
|
||||||
|
$nbSessions = 0;
|
||||||
|
foreach ($sessionStats as $row)
|
||||||
|
{
|
||||||
|
if ( ($userId == -1 || $row->userId == $userId) && ($sessionId == -1 || $row->sessionId == $sessionId) )
|
||||||
|
{
|
||||||
|
// Increase number of sessions for this user
|
||||||
|
$nbSessions++;
|
||||||
|
|
||||||
|
// Update precision average
|
||||||
|
$newPrecision = $userGlobals->totals->averagePrecision*$userGlobals->totals->nbFiredShotsByUser + $row->averagePrecision*$row->nbFiredShotsByUser;
|
||||||
|
$userGlobals->totals->averagePrecision = $newPrecision/max($userGlobals->totals->nbFiredShotsByUser + $row->nbFiredShotsByUser, 1.0);
|
||||||
|
|
||||||
|
// Update reactionTime average
|
||||||
|
if ($row->averageReactionTime > 0)
|
||||||
|
{
|
||||||
|
$newReactionTime = $userGlobals->totals->averageReactionTime*$newNbFiredShotsForReactionTime + $row->averageReactionTime*$row->nbFiredShotsByUser;
|
||||||
|
$newNbFiredShotsForReactionTime += $row->nbFiredShotsByUser;
|
||||||
|
$userGlobals->totals->averageReactionTime = $newReactionTime/max($newNbFiredShotsForReactionTime, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update other fields
|
||||||
|
$userGlobals->totals->nbFiredShotsByUser = $userGlobals->totals->nbFiredShotsByUser + $row->nbFiredShotsByUser;
|
||||||
|
$userGlobals->totals->nbEnemyHitsByUser += $row->nbEnemyHitsByUser;
|
||||||
|
$userGlobals->totals->nbCivilHitsByUser += $row->nbCivilHitsByUser;
|
||||||
|
$userGlobals->totals->nbPoliceHitsByUser += $row->nbPoliceHitsByUser;
|
||||||
|
$userGlobals->totals->nbObjectHitsByUser += $row->nbObjectHitsByUser;
|
||||||
|
$userGlobals->totals->nbMissedShotsByUser += $row->nbMissedShotsByUser;
|
||||||
|
$userGlobals->totals->nbDeadBodyHitsByUser += $row->nbDeadBodyHitsByUser;
|
||||||
|
$userGlobals->totals->nbReceivedHitsFromEnemyIA += $row->nbReceivedHitsFromEnemyIA;
|
||||||
|
$userGlobals->totals->nbReceivedHitsFromEnemyUser += $row->nbReceivedHitsFromEnemyUser;
|
||||||
|
$userGlobals->totals->nbReceivedHitsFromPoliceUser +=$row->nbReceivedHitsFromPoliceUser;
|
||||||
|
$userGlobals->totals->totalEnemyKilled += $row->totalEnemyKilled;
|
||||||
|
$userGlobals->totals->totalCivilKilled += $row->totalCivilKilled;
|
||||||
|
$userGlobals->totals->totalPoliceKilled += $row->totalPoliceKilled;
|
||||||
|
|
||||||
|
// Update duration
|
||||||
|
$userGlobals->totalDuration += $row->session->timeToFinish;
|
||||||
|
|
||||||
|
// Update user and session ids
|
||||||
|
$userGlobals->totals->userId = $row->userId;
|
||||||
|
$userGlobals->totals->sessionId = $row->sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get number of sessions user took part in
|
||||||
|
$userGlobals->nbSessions = $nbSessions;
|
||||||
|
|
||||||
|
// Calculate averages for precision and reaction time
|
||||||
|
$query = "SELECT SD.*, SUM(SD.HitPrecision) AS TotalPrecision, COUNT(DISTINCT SD.ShotIndex, SD.TargetName) AS TotalShots" .
|
||||||
|
", SUM(SD.ReactionTime) AS TotalReactionTime, SUM(CEILING(SD.HitPrecision)) AS TotalHits, X.MinDate AS MinDate, X.MaxDate AS MaxDate" .
|
||||||
|
" FROM " . SESSIONDEBRIEFS_VIEW_NAME . " SD, " .
|
||||||
|
" (SELECT P.userID AS UserId, MIN(S.SessionDate) AS MinDate, MAX(S.SessionDate) AS MaxDate" .
|
||||||
|
//" FROM " . PARTICIPATES_TABLE_NAME . " P INNER JOIN " . SESSIONS_TABLE_NAME . " S ON S.id=P.sessionId WHERE 1 " . $userSubConstraint . " GROUP BY P.userId) AS X" .
|
||||||
|
" FROM " . PARTICIPATES_TABLE_NAME . " P, " . SESSIONS_TABLE_NAME . " S WHERE S.id=P.sessionId " . $userSubConstraint . " GROUP BY P.userId) AS X" .
|
||||||
|
" WHERE 1 " . $sessionConstraint . $userConstraint . " GROUP BY SD.SessionId, SD.ShooterId";
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
// read MIN and MAX dates
|
||||||
|
$userGlobals->firstSession = $row['MinDate'];
|
||||||
|
$userGlobals->lastSession = $row['MaxDate'];
|
||||||
|
|
||||||
|
// replace HitPrecision, ReactionTime and NbHit fields so that we don't have to create a new type for returning the results
|
||||||
|
$row['HitPrecision'] = (float)(($row['TotalPrecision'] ?? 0.0) / max(($row['TotalShots'] ?? 1.0), 1.0) );
|
||||||
|
$row['ReactionTime'] = (float)(($row['TotalReactionTime'] ?? 0.0) / max(($row['TotalHits'] ?? 1.0), 1.0) );
|
||||||
|
$row['NbHit'] = $row['TotalHits'] ?? 0;
|
||||||
|
|
||||||
|
$stats = SessionDebriefRow::withRow($row);
|
||||||
|
array_push( $userGlobals->sessionDebriefRows, $stats->toArray() );
|
||||||
|
//array_push( $this->elements, $stats->toArray() ); // old
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userGlobals->nbSessions == 1)
|
||||||
|
{
|
||||||
|
$userGlobals->firstSession = $sessionStats[0]->session->sessionDate;
|
||||||
|
$userGlobals->lastSession = $userGlobals->firstSession;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
array_push( $this->elements, $userGlobals->toArray() ); // new
|
||||||
|
//return $userGlobals->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// debrief mission : get totals for given session and/or user
|
||||||
|
public function getReceivedHitsForUser ($sessionId, $userId, $fromUser=-1)
|
||||||
|
{
|
||||||
|
// create SQL constraints for query
|
||||||
|
$sessionConstraint = $sessionId > 0 ? " AND SDRE.srcEventSessionId = " . $sessionId : "";
|
||||||
|
$userConstraint = $userId > 0 ? " AND SDRE.hitUserId=" . $userId : "";
|
||||||
|
|
||||||
|
$query = "SELECT SDRE.srcEventSessionId AS SessionId, COALESCE(SDRE.hitUserId, -1) AS UserId,
|
||||||
|
SUM(CASE WHEN X.ShooterRoleId=3 THEN X.NbHits ELSE 0 END) AS NbEnemyShotsIA,
|
||||||
|
SUM(CASE WHEN X.ShooterRoleId=1 THEN X.NbHits ELSE 0 END) AS NbEnemyShotsUser,
|
||||||
|
SUM(CASE WHEN X.ShooterRoleId=0 THEN X.NbHits ELSE 0 END) AS NbPoliceHits
|
||||||
|
FROM " . REACTEVENTS_TABLE_NAME . " SDRE LEFT JOIN
|
||||||
|
(SELECT TE.SessionId AS SessionId, RE.hitUserId AS TargetUserId, TE.srcUserId AS ShooterRoleId, COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS NbHits, RE.id AS ReactId
|
||||||
|
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 GROUP BY SessionId, TargetUserId, ShooterRoleId) AS X
|
||||||
|
ON (X.SessionId=SDRE.srcEventSessionId AND X.TargetUserId=SDRE.hitUserId AND X.ReactId=SDRE.id) " .
|
||||||
|
" LEFT JOIN " . SESSIONS_TABLE_NAME . " SD ON (SD.id=SDRE.srcEventSessionId) " .
|
||||||
|
"WHERE 1 " . $sessionConstraint . $userConstraint . $this->getSessionDurationConstraint("SD") . " GROUP BY SDRE.srcEventSessionId, SDRE.hitUserId";
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// debrief mission : get totals for given session and/or user
|
||||||
|
public function getShotsFiredByUser ($sessionId, $userId, $fromUser=-1)
|
||||||
|
{
|
||||||
|
// create SQL constraints for query
|
||||||
|
$sessionConstraint = $sessionId > 0 ? " AND SDTE.SessionId = " . $sessionId : "";
|
||||||
|
$userConstraint = $userId > 0 ? " AND SDTE.srcUserId=" . $userId : "";
|
||||||
|
|
||||||
|
$query = "SELECT SDTE.SessionId AS SessionId, SDTE.srcUserId AS UserId,
|
||||||
|
COALESCE(X.NbShotsFired,0) AS NbShotsFired,
|
||||||
|
COALESCE(X.NbShotsFired,0) - COALESCE(SUM(CASE WHEN Y.ReactTypeId>=0 AND Y.ReactTypeId<6 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END),0) AS NbMissedShots,
|
||||||
|
SUM(CASE WHEN Y.ReactTypeId=0 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbEnemyHits,
|
||||||
|
SUM(CASE WHEN Y.ReactTypeId=1 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbCivilHits,
|
||||||
|
SUM(CASE WHEN Y.ReactTypeId=2 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbPoliceHits,
|
||||||
|
SUM(CASE WHEN (Y.ReactTypeId=4 OR Y.ReactTypeId=5) AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbObjectHits,
|
||||||
|
SUM(CASE WHEN Y.ReactTypeId=6 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbDeadBodyHits
|
||||||
|
FROM " . SESSIONS_TABLE_NAME . " SD LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " SDTE ON (SD.id = SDTE.sessionId)
|
||||||
|
LEFT JOIN " . REACTEVENTS_TABLE_NAME . " SDRE ON (SDTE.sessionId=SDRE.srcEventSessionId AND SDTE.indexCount=SDRE.srcEventIndex)
|
||||||
|
LEFT JOIN
|
||||||
|
(SELECT TE.sessionId AS SessionId, TE.srcUserId AS ShooterId, COUNT(DISTINCT TE.indexCount) AS NbShotsFired
|
||||||
|
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE GROUP BY TE.sessionId, TE.srcUserId) AS X
|
||||||
|
ON (X.SessionId=SDTE.SessionId AND X.ShooterId=SDTE.srcUserId)
|
||||||
|
LEFT JOIN
|
||||||
|
(SELECT TE.sessionId AS SessionId, TE.srcUserId AS ShooterId, RE.ReactType AS ReactTypeId, COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS NbHits, RE.id AS ReactId, RE.hitPrecision AS ReactPrecision
|
||||||
|
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE
|
||||||
|
WHERE TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 GROUP BY SessionId, ShooterId, ReactTypeId, ReactID) AS Y
|
||||||
|
ON (Y.SessionId=SDTE.SessionId AND Y.ShooterId=SDTE.srcUserId AND Y.ReactId=SDRE.id)
|
||||||
|
LEFT JOIN
|
||||||
|
(SELECT TE1.sessionId AS SessionId, TE1.srcUserId AS ShooterId, COALESCE(COUNT(DISTINCT TE1.sessionId, TE1.indexCount),0) AS NbMissedShots
|
||||||
|
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE1, " . REACTEVENTS_TABLE_NAME . " RE1
|
||||||
|
WHERE TE1.sessionId=RE1.srcEventSessionId AND TE1.indexCount NOT IN (SELECT RE2.srcEventIndex FROM " . REACTEVENTS_TABLE_NAME . " RE2 WHERE RE2.srcEventSessionId=RE1.srcEventSessionId)) AS Z
|
||||||
|
ON (Z.SessionId=SDTE.SessionId AND Z.ShooterId=SDTE.srcUserId)
|
||||||
|
WHERE 1 " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $sessionConstraint . $userConstraint . " GROUP BY SDTE.SessionId, SDTE.srcUserId";
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// debrief mission : get totals for given session and/or user
|
||||||
|
public function getTargetKilledInSession ($sessionId)
|
||||||
|
{
|
||||||
|
// create SQL constraints for query
|
||||||
|
$sessionConstraint = $sessionId > 0 ? " AND SDTE.SessionId = " . $sessionId : "";
|
||||||
|
|
||||||
|
$query = "SELECT SDTE.SessionId AS SessionId, COALESCE(SDTE.srcUserId,-1) AS UserId,
|
||||||
|
SUM(CASE WHEN X.ReactTypeId=0 THEN X.NbHits ELSE 0 END) AS NbEnemyKilled,
|
||||||
|
SUM(CASE WHEN X.ReactTypeId=1 THEN X.NbHits ELSE 0 END) AS NbCivilKilled,
|
||||||
|
SUM(CASE WHEN X.ReactTypeId=2 THEN X.NbHits ELSE 0 END) AS NbPoliceKilled
|
||||||
|
FROM " . SESSIONS_TABLE_NAME . " SD LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " SDTE ON (SD.id = SDTE.sessionId)
|
||||||
|
LEFT JOIN " . REACTEVENTS_TABLE_NAME . " SDRE ON (SDTE.sessionId=SDRE.srcEventSessionId AND SDTE.indexCount=SDRE.srcEventIndex)
|
||||||
|
LEFT JOIN
|
||||||
|
(SELECT TE.SessionId AS SessionId, RE.hitUserId AS TargetUserId, TE.srcUserId AS ShooterRoleId, COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS NbHits, RE.id AS ReactId, RE.ReactType AS ReactTypeId
|
||||||
|
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 AND RE.TargetKilled=1
|
||||||
|
GROUP BY SessionId, TargetUserId, ShooterRoleId, ReactTypeId) AS X
|
||||||
|
ON (X.SessionId=SDTE.SessionId AND X.TargetUserId=SDRE.hitUserId AND X.ReactId=SDRE.id)
|
||||||
|
WHERE 1 " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $sessionConstraint . " GROUP BY SDTE.SessionId";
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// debrief mission : get averages for given session and/or user
|
||||||
|
public function getPrecisionAndReactionTimeForUser ($sessionId, $userId, $fromUser=-1)
|
||||||
|
{
|
||||||
|
// create SQL constraints for query
|
||||||
|
$sessionConstraint = $sessionId > 0 ? " AND SDTE.SessionId = " . $sessionId : "";
|
||||||
|
|
||||||
|
$query = "SELECT SDTE.SessionId AS SessionId, SDTE.srcUserId AS UserId, AVG(X.AvgHitPrec) AS AveragePrecision,
|
||||||
|
AVG(CASE WHEN X.HitPrecision > 0 AND X.ReactId > 0 THEN X.AvgReactTime END) AS AverageReactionTime
|
||||||
|
FROM " . SESSIONS_TABLE_NAME . " SD LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " SDTE ON (SD.id = SDTE.sessionId)
|
||||||
|
LEFT JOIN " . REACTEVENTS_TABLE_NAME . " SDRE ON SDTE.sessionId=SDRE.srcEventSessionId AND SDTE.indexCount=SDRE.srcEventIndex
|
||||||
|
LEFT JOIN
|
||||||
|
(SELECT DISTINCT TE.SessionId AS SessionId, TE.srcUserId AS ShooterId, TE.indexCount, RE.hitTargetName, COALESCE(AVG(RE.hitPrecision),0) AS AvgHitPrec, COALESCE(AVG(RE.reactTime),0) AS AvgReactTime, COALESCE(RE.id,-1) AS ReactId, COALESCE(RE.hitPrecision,0) AS HitPrecision
|
||||||
|
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE LEFT JOIN " . REACTEVENTS_TABLE_NAME . " RE ON TE.indexCount=RE.srcEventIndex AND TE.sessionId=RE.srcEventSessionId GROUP BY TE.SessionId, TE.srcUserId, TE.indexCount, RE.id, RE.hitTargetName) AS X
|
||||||
|
ON X.SessionId=SDTE.SessionId AND X.ShooterId=SDTE.srcUserId
|
||||||
|
WHERE 1 " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $sessionConstraint . " GROUP BY SDTE.SessionId, SDTE.srcUserId";
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// debrief mission : get given users results in given session
|
||||||
|
public function getResultsForSession ($sessionId, $userId, $fromUserId=-1)
|
||||||
|
{
|
||||||
|
$userConstraint = $userId > 0 ? " AND SD.ShooterId=" . $userId : "";
|
||||||
|
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId=" . $sessionId : "";
|
||||||
|
|
||||||
|
$query = "SELECT SD.* FROM " . SESSIONDEBRIEFS_VIEW_NAME . " SD WHERE 1 " . $this->getCalibrationSessionsConstraint() . $userConstraint . $sessionConstraint . " GROUP BY SD.SessionId, SD.ShooterId, SD.ShotIndex";
|
||||||
|
//$query = "SELECT SD.* FROM (" . SESSIONDEBRIEFS_VIEW_QUERY . ") AS SD WHERE 1 " . $userConstraint . $sessionConstraint . " GROUP BY SD.SessionId, SD.ShooterId, SD.ShotIndex";
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
$shot = SessionDebriefRow::withRow($row);
|
||||||
|
array_push( $this->elements, $shot->toArray() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// debrief mission : get stats for given session and/or user
|
||||||
|
/*public function getSessionDebrief ($sessionId, $userId, $fromUser=-1)
|
||||||
|
{
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->getDebriefRows($sessionId, $userId);
|
||||||
|
|
||||||
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
|
{
|
||||||
|
// create a SessionDebriefRow with this data
|
||||||
|
$stats = SessionDebriefRow::withRow($row);
|
||||||
|
array_push( $this->elements, $stats->toArray() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function getDebriefRows ($sessionId, $userId)
|
||||||
|
{
|
||||||
|
// create SQL constraints for query
|
||||||
|
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId = " . $sessionId : "";
|
||||||
|
$userConstraint = $userId >= 0 ? " AND (SD.ShooterId = " . $userId . " OR SD.TargetUserId = " . $userId . ")" : "";
|
||||||
|
|
||||||
|
//$query = "SELECT SD.* FROM " . SESSIONDEBRIEFS_VIEW_NAME . " SD WHERE 1 " . $sessionConstraint . $userConstraint;
|
||||||
|
$query = "SELECT SD.* FROM (" . SESSIONDEBRIEFS_VIEW_QUERY . ") AS SD WHERE 1 " . $sessionConstraint . $userConstraint;
|
||||||
|
|
||||||
|
// prepare and execute query
|
||||||
|
$stmt = $this->conn->prepare($query);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt;
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/stats_user_in_session_row.php';
|
||||||
|
|
||||||
|
class UserGlobalStats
|
||||||
|
{
|
||||||
|
public int $nbSessions = 0;
|
||||||
|
public float $totalDuration = 0.0;
|
||||||
|
|
||||||
|
public string $firstSession = "";
|
||||||
|
public string $lastSession = "";
|
||||||
|
|
||||||
|
public UserStatsInSessionRow $totals;
|
||||||
|
|
||||||
|
public $sessionDebriefRows = array();
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function __construct ()
|
||||||
|
{
|
||||||
|
$this->totals = new UserStatsInSessionRow();
|
||||||
|
$this->sessionDebriefRows = array();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function setSessionTotals (array $row)
|
||||||
|
{
|
||||||
|
$this->nbSessions = (int)$row['NbSessions'];
|
||||||
|
$this->totalDuration = (float)$row['TotalDuration'];
|
||||||
|
$this->firstSession = $row['MinDate'];
|
||||||
|
$this->lastSession = $row['MaxDate'];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"nbSessions" => (int)$this->nbSessions,
|
||||||
|
"totalDuration" => (float)$this->totalDuration ?? 0.0,
|
||||||
|
"firstSessionAsString" => $this->firstSession ?? "",
|
||||||
|
"lastSessionAsString" => $this->lastSession ?? "",
|
||||||
|
"totals" => $this->totals->toArray(),
|
||||||
|
"sessionDebriefRows" => $this->sessionDebriefRows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
include_once '../objects/db_user.php';
|
||||||
|
include_once '../objects/db_session.php';
|
||||||
|
|
||||||
|
class UserStatsInSessionRow
|
||||||
|
{
|
||||||
|
public GameSession $session;
|
||||||
|
public User $user;
|
||||||
|
|
||||||
|
public int $sessionId = -1;
|
||||||
|
public int $userId = -1;
|
||||||
|
|
||||||
|
public int $nbFiredShotsByUser = 0;
|
||||||
|
public int $nbEnemyHitsByUser = 0;
|
||||||
|
public int $nbCivilHitsByUser = 0;
|
||||||
|
public int $nbPoliceHitsByUser = 0;
|
||||||
|
public int $nbObjectHitsByUser = 0;
|
||||||
|
public int $nbMissedShotsByUser = 0;
|
||||||
|
public int $nbDeadBodyHitsByUser = 0;
|
||||||
|
|
||||||
|
public float $averagePrecision = 0.0;
|
||||||
|
public float $averageReactionTime = 0.0;
|
||||||
|
|
||||||
|
public int $nbReceivedHitsFromEnemyIA = 0;
|
||||||
|
public int $nbReceivedHitsFromEnemyUser = 0;
|
||||||
|
public int $nbReceivedHitsFromPoliceUser = 0;
|
||||||
|
|
||||||
|
public int $totalEnemyKilled = 0;
|
||||||
|
public int $totalCivilKilled = 0;
|
||||||
|
public int $totalPoliceKilled = 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function __construct ()
|
||||||
|
{
|
||||||
|
$this->session = new GameSession(null);
|
||||||
|
$this->sessionId = -1;
|
||||||
|
$this->user = new User(null);
|
||||||
|
$this->userId = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public static function withRow ($db, array $row)
|
||||||
|
{
|
||||||
|
$instance = new self();
|
||||||
|
$instance->createSession($db, (int)$row['SessionId']);
|
||||||
|
$instance->createUser($db, (int)$row['UserId']);
|
||||||
|
//$this->readRow($row);
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function createSessionAndUser ($db, int $sessionId=-1, int $userId=-1)
|
||||||
|
{
|
||||||
|
if ($sessionId > -1)
|
||||||
|
$this->createSession($db, $sessionId);
|
||||||
|
|
||||||
|
if ($userId > 0)
|
||||||
|
$this->createUser($db, $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
private function createSession ($db, int $id)
|
||||||
|
{
|
||||||
|
$this->session = new GameSession($db);
|
||||||
|
$this->session->id = $id;
|
||||||
|
$this->sessionId = $id; // keeping this field for easier access in UE
|
||||||
|
$this->session->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
private function createUser ($db, int $id)
|
||||||
|
{
|
||||||
|
$this->user = new User($db);
|
||||||
|
$this->user->id = $id;
|
||||||
|
$this->userId = $id; // keeping this field for easier access in UE
|
||||||
|
$this->user->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
function readRow (array $row)
|
||||||
|
{
|
||||||
|
$this->sessionId = $row['SessionId'];
|
||||||
|
//$this->sessionTypeId = $row['SessionTypeId'];
|
||||||
|
//$this->sessionType = $row['SessionType'];
|
||||||
|
//$this->sessionName = $row['SessionName'];
|
||||||
|
//$this->sessionDate = $row['SessionDate'];
|
||||||
|
//$this->mapName = $row['MapName'];
|
||||||
|
//$this->scenarioName = $row['ScenarioName'];
|
||||||
|
//$this->sessionSuccessful = $row['SessionSuccessful'] == 1;
|
||||||
|
//$this->sessionDuration = (float)$row['SessionDuration'];
|
||||||
|
//$this->triggerTypeId = (int)$row['TriggerTypeId'];
|
||||||
|
//$this->triggerType = $row['TriggerType'];
|
||||||
|
//$this->shooterId = (int)$row['ShooterId'];
|
||||||
|
//$this->shooterName = $row['ShooterName'];
|
||||||
|
//$this->shooterRoleId = (int)$row['ShooterRoleId'];
|
||||||
|
//$this->shooterRole = $row['ShooterRole'];
|
||||||
|
//$this->shotIndex = (int)$row['ShotIndex'];
|
||||||
|
//$this->reactId = (int)$row['ReactId'];
|
||||||
|
//$this->reactModeId = (int)$row['ReactModeId'];
|
||||||
|
//$this->reactMode = $row['ReactMode'];
|
||||||
|
//$this->reactTypeId = (int)$row['ReactTypeId'];
|
||||||
|
//$this->reactType = $row['ReactType'];
|
||||||
|
//$this->targetUserId = (int)$row['TargetUserId'];
|
||||||
|
//$this->targetUserName = $row['TargetUserName'];
|
||||||
|
//$this->targetRoleId = (int)$row['TargetRoleId'];
|
||||||
|
//$this->targetRole = $row['TargetRole'];
|
||||||
|
//$this->targetName = $row['TargetName'];
|
||||||
|
//$this->targetBoneName = $row['TargetBoneName'];
|
||||||
|
//$this->targetKilled = $row['TargetKilled'] == 1;
|
||||||
|
//$this->hitLocationX = (float)$row['HitLocationX'];
|
||||||
|
//$this->hitLocationY = (float)$row['HitLocationY'];
|
||||||
|
//$this->hitLocationTag = $row['HitLocationTag'];
|
||||||
|
//$this->hitPrecision = (float)$row['HitPrecision'];
|
||||||
|
//$this->hitTargetDistance = (float)$row['HitTargetDistance'];
|
||||||
|
//$this->reactTime = (float)$row['ReactionTime'];
|
||||||
|
//$this->timeStamp = (float)$row['TimeStamp'];
|
||||||
|
//$this->nbHit = (int)$row['NbHit'];
|
||||||
|
//$this->nbKilled = (int)$row['NbKilled'];
|
||||||
|
$this->userId = $row['UserId'];
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function setFiredShots (array $row)
|
||||||
|
{
|
||||||
|
$this->nbFiredShotsByUser = (int)$row['NbShotsFired'];
|
||||||
|
$this->nbMissedShotsByUser = (int)$row['NbMissedShots'];
|
||||||
|
$this->nbEnemyHitsByUser = (int)$row['NbEnemyHits'];
|
||||||
|
$this->nbCivilHitsByUser = (int)$row['NbCivilHits'];
|
||||||
|
$this->nbPoliceHitsByUser = (int)$row['NbPoliceHits'];
|
||||||
|
$this->nbObjectHitsByUser = (int)$row['NbObjectHits'];
|
||||||
|
$this->nbDeadBodyHitsByUser = (int)$row['NbDeadBodyHits'];
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function setPrecisionAndReactionTime (array $row)
|
||||||
|
{
|
||||||
|
$this->averagePrecision = (float)$row['AveragePrecision'];
|
||||||
|
$this->averageReactionTime = (float)$row['AverageReactionTime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function setReceivedHits (array $row)
|
||||||
|
{
|
||||||
|
$this->nbReceivedHitsFromEnemyIA = (int)$row['NbEnemyShotsIA'];
|
||||||
|
$this->nbReceivedHitsFromEnemyUser = (int)$row['NbEnemyShotsUser'];
|
||||||
|
$this->nbReceivedHitsFromPoliceUser = (int)$row['NbPoliceHits'];
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function setTotalKills (array $row)
|
||||||
|
{
|
||||||
|
$this->totalEnemyKilled = (int)$row['NbEnemyKilled'];
|
||||||
|
$this->totalCivilKilled = (int)$row['NbCivilKilled'];
|
||||||
|
$this->totalPoliceKilled = (int)$row['NbPoliceKilled'];
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"session" => $this->session->toArray() ?? "",
|
||||||
|
"user" => $this->user->toArray() ?? "",
|
||||||
|
"userId" => (int)$this->userId ?? -1,
|
||||||
|
"sessionId" => (int)$this->sessionId ?? -1,
|
||||||
|
"userId" => (int)$this->userId ?? -1,
|
||||||
|
"nbFiredShotsByUser" => (int)$this->nbFiredShotsByUser ?? 0,
|
||||||
|
"nbEnemyHitsByUser" => (int)$this->nbEnemyHitsByUser ?? 0,
|
||||||
|
"nbCivilHitsByUser" => (int)$this->nbCivilHitsByUser ?? 0,
|
||||||
|
"nbPoliceHitsByUser" => (int)$this->nbPoliceHitsByUser ?? 0,
|
||||||
|
"nbObjectHitsByUser" => (int)$this->nbObjectHitsByUser ?? 0,
|
||||||
|
"nbDeadBodyHitsHitsByUser" => (int)$this->nbDeadBodyHitsByUser ?? 0,
|
||||||
|
"nbMissedShotsByUser" => (int)$this->nbMissedShotsByUser ?? 0,
|
||||||
|
"averagePrecision" => (float)$this->averagePrecision ?? 0.0,
|
||||||
|
"averageReactionTime" => (float)$this->averageReactionTime ?? 0.0,
|
||||||
|
"nbReceivedHitsFromEnemyIA" => (int)$this->nbReceivedHitsFromEnemyIA ?? 0,
|
||||||
|
"nbReceivedHitsFromEnemyUser" => (int)$this->nbReceivedHitsFromEnemyUser ?? 0,
|
||||||
|
"nbReceivedHitsFromPoliceUser" => (int)$this->nbReceivedHitsFromPoliceUser ?? 0,
|
||||||
|
"totalEnemyKilled" => (int)$this->totalEnemyKilled ?? 0,
|
||||||
|
"totalCivilKilled" => (int)$this->totalCivilKilled ?? 0,
|
||||||
|
"totalPoliceKilled" => (int)$this->totalPoliceKilled ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
public function isForUserInSession ($userIdToCheck, $sessionIdToCheck)
|
||||||
|
{
|
||||||
|
return $this->userId == $userIdToCheck && $this->sessionId == $sessionIdToCheck;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Stats_ShotsPerUserPerSession_Row
|
||||||
|
{
|
||||||
|
public int $sessionId = -1;
|
||||||
|
public int $userId = -1;
|
||||||
|
public $username = "";
|
||||||
|
public int $nbShotsFired = 0;
|
||||||
|
public int $nbEnemyHits = 0;
|
||||||
|
public int $nbCivilsHits = 0;
|
||||||
|
public int $nbObjectHits = 0;
|
||||||
|
public int $nbDeadHits = 0;
|
||||||
|
|
||||||
|
public function __construct($row)
|
||||||
|
{
|
||||||
|
$this->sessionId = $row['sessionId'];
|
||||||
|
$this->userId = $row['userId'];
|
||||||
|
$this->username = $row['username'];
|
||||||
|
$this->nbShotsFired = $row['nbShotsFired'];
|
||||||
|
$this->nbEnemyHits = $row['nbEnemyHits'];
|
||||||
|
$this->nbCivilsHits = $row['nbCivilsHits'];
|
||||||
|
$this->nbObjectHits = $row['nbObjectHits'];
|
||||||
|
$this->nbDeadHits = $row['nbDeadHits'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray () : array
|
||||||
|
{
|
||||||
|
return array (
|
||||||
|
"sessionId" => (int)$this->sessionId ?? -1,
|
||||||
|
"userId" => (int)$this->userId ?? -1,
|
||||||
|
"username" => $this->username ?? "",
|
||||||
|
"nbShotsFired" => (int)$this->nbShotsFired ?? 0,
|
||||||
|
"nbEnemyHits" => (int)$this->nbEnemyHits ?? 0,
|
||||||
|
"nbCivilsHits" => (int)$this->nbCivilsHits ?? 0,
|
||||||
|
"nbObjectHits" => (int)$this->nbObjectHits ?? 0,
|
||||||
|
"nbDeadHits" => (int)$this->nbDeadHits ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/init.php'; // contains $db
|
||||||
|
include_once '../config/error.php';
|
||||||
|
include_once '../objects/db_participates.php';
|
||||||
|
//include_once './score_algo1.php';
|
||||||
|
|
||||||
|
// prepare user object
|
||||||
|
$participation = new UserInGameSession($db);
|
||||||
|
|
||||||
|
// ensure sessionId and userId are passed in $_POST parameters
|
||||||
|
if (isset($_POST['sessionId']))
|
||||||
|
$participation->sessionId = $_POST['sessionId'];
|
||||||
|
else trigger_error( missing_parameter_error("sessionId") );
|
||||||
|
|
||||||
|
if (isset($_POST['userId']))
|
||||||
|
$participation->userId = $_POST['userId'];
|
||||||
|
else trigger_error( missing_parameter_error("userId") );
|
||||||
|
|
||||||
|
// calculate user score in session
|
||||||
|
if ($participation->updateUserScores())
|
||||||
|
{
|
||||||
|
$participation_arr= $participation->getResultArray(true,"Score_Updated_OK");
|
||||||
|
print_r(json_encode($participation_arr)); // OK
|
||||||
|
}
|
||||||
|
else trigger_error("User_Score_Not_Updated");
|
||||||
|
?>
|
||||||
23
version/proserve-ReleaseTemplate/_api/score/score_algo1.php
Normal file
23
version/proserve-ReleaseTemplate/_api/score/score_algo1.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
function calculate_v1 ($precisionOnEnemies, $nbCivils)
|
||||||
|
{
|
||||||
|
$pointsPerEnemy = 100;
|
||||||
|
$penaltyPerCivil = 200;
|
||||||
|
|
||||||
|
return $precisionOnEnemies*$pointsPerEnemy - $nbCivils*$penaltyPerCivil;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculate_firerange_v1 ($precision)
|
||||||
|
{
|
||||||
|
$pointsPerShot = 10;
|
||||||
|
|
||||||
|
return $precision*$pointsPerShot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function is_success_v1 ($score)
|
||||||
|
{
|
||||||
|
$targetScore = 70;
|
||||||
|
|
||||||
|
return $score >= $targetScore;
|
||||||
|
}
|
||||||
|
?>
|
||||||
27
version/proserve-ReleaseTemplate/_api/session/debrief.php
Normal file
27
version/proserve-ReleaseTemplate/_api/session/debrief.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/init.php'; // contains $db
|
||||||
|
include_once '../config/error.php';
|
||||||
|
include_once '../objects/stats_object.php';
|
||||||
|
|
||||||
|
$sessionId = -1;
|
||||||
|
|
||||||
|
// read mandatory $_POST properties : ensure sessionId is passed in $_POST parameters
|
||||||
|
if (isset($_POST['sessionId']))
|
||||||
|
$sessionId = $_POST['sessionId'];
|
||||||
|
else trigger_error( missing_parameter_error("sessionId") );
|
||||||
|
|
||||||
|
$userId = isset($_POST['userId']) ? $_POST['userId'] : -1;
|
||||||
|
|
||||||
|
// check sessionId is > 0
|
||||||
|
//if ($sessionId > 0)
|
||||||
|
//{
|
||||||
|
// prepare user object
|
||||||
|
$stats = new StatsObject($db);
|
||||||
|
$stats->getStatsForSession($sessionId, $userId);
|
||||||
|
|
||||||
|
$stats_arr = $stats->getResultArray(true, "Stats_Collected_OK");
|
||||||
|
print_r(json_encode($stats_arr)); // OK
|
||||||
|
//}
|
||||||
|
//else trigger_error("Invalid session id");
|
||||||
|
?>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/init.php'; // contains $db
|
||||||
|
include_once '../config/error.php';
|
||||||
|
include_once '../objects/stats_object.php';
|
||||||
|
|
||||||
|
$stats = new StatsObject($db);
|
||||||
|
$nb = $stats->fixDurations();
|
||||||
|
|
||||||
|
/*if ($nb == 0)
|
||||||
|
echo "No session to be corrected.";
|
||||||
|
else if ($nb == 1)
|
||||||
|
echo $nb . " session has been corrected.";
|
||||||
|
else
|
||||||
|
echo $nb . " sessions have been corrected.";*/
|
||||||
|
?>
|
||||||
22
version/proserve-ReleaseTemplate/_api/session/get.php
Normal file
22
version/proserve-ReleaseTemplate/_api/session/get.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
// include database and object files
|
||||||
|
include_once '../config/init.php'; // contains $db
|
||||||
|
include_once '../config/error.php';
|
||||||
|
include_once '../objects/db_session.php';
|
||||||
|
|
||||||
|
// prepare user object
|
||||||
|
$gameSession = new GameSession($db);
|
||||||
|
|
||||||
|
// read mandatory $_POST properties : ensure sessionId is passed in $_POST parameters
|
||||||
|
if (isset($_POST['sessionId']))
|
||||||
|
$gameSession->id = $_POST['sessionId'];
|
||||||
|
else trigger_error( missing_parameter_error("sessionId") );
|
||||||
|
|
||||||
|
// create the game session
|
||||||
|
if ($gameSession->load())
|
||||||
|
{
|
||||||
|
$session_arr = $gameSession->getResultArray(true, "Session_Found");
|
||||||
|
print_r(json_encode($session_arr)); // OK
|
||||||
|
}
|
||||||
|
else trigger_error("Error_Occured");
|
||||||
|
?>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user