Compare commits
27 Commits
7fb02e4945
...
c50abeb8d0
| Author | SHA1 | Date | |
|---|---|---|---|
| c50abeb8d0 | |||
| fd1e9e9e6b | |||
| 886245cc61 | |||
| 42dd0701f5 | |||
| ab40b62430 | |||
| 24d1841844 | |||
| 6d11b43d17 | |||
| 89c78c9b37 | |||
| 27fa793351 | |||
| d09deaddb7 | |||
| c706d09e72 | |||
| ce3979d131 | |||
| 64fbda0191 | |||
| 1acb735641 | |||
| 562b4e5ed0 | |||
| 67f422539d | |||
| a2e67d5405 | |||
| 7a09f73c44 | |||
| 6d251e9eac | |||
| 288cad585a | |||
| f62b212fc1 | |||
| f86895553a | |||
| 1473e5185c | |||
| 333eb24db6 | |||
| 750bb8cfc9 | |||
| 6f8a320d69 | |||
| 48d601176d |
30
.gitignore
vendored
30
.gitignore
vendored
@@ -28,6 +28,17 @@ packages/
|
|||||||
# Large game build (sample only — should not be in repo)
|
# Large game build (sample only — should not be in repo)
|
||||||
ASTERION_VR/
|
ASTERION_VR/
|
||||||
|
|
||||||
|
# Sources des releases PROSERVE en attente de zip/upload :
|
||||||
|
# on ignore tout sauf les _migrations/ (SQL source-of-truth de la DB).
|
||||||
|
# Le _report/ est un build artifact (Vue/React minifié) maintenu ailleurs.
|
||||||
|
# Les binaires UE5 sont gigaoctets, à pousser uniquement via SFTP.
|
||||||
|
version/*
|
||||||
|
!version/.gitkeep
|
||||||
|
!version/*/
|
||||||
|
version/*/*
|
||||||
|
!version/*/_migrations/
|
||||||
|
version/*/_migrations/.htaccess
|
||||||
|
|
||||||
# Server-side secrets — never commit (config.example.php sert de template)
|
# Server-side secrets — never commit (config.example.php sert de template)
|
||||||
server/api/config.php
|
server/api/config.php
|
||||||
# Le contenu de server/builds/ est ignoré (gros ZIPs / launcher.exe), mais on
|
# Le contenu de server/builds/ est ignoré (gros ZIPs / launcher.exe), mais on
|
||||||
@@ -41,11 +52,20 @@ server/builds/*
|
|||||||
# Plans
|
# Plans
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Binaires copiés par le post-publish à la racine du repo
|
# Binaires copiés par le post-publish à la racine du repo (glob unique qui
|
||||||
/PSLauncher.exe
|
# couvre PS_Launcher.exe, PS_Launcher.Updater.exe, PS_Launcher-X.Y.Z.exe,
|
||||||
/PSLauncher.Updater.exe
|
# PS_Launcher_X.Y.Z.exe — séparateur tiret ou underscore — et les .bak).
|
||||||
/PSLauncher-*.exe
|
/PS_Launcher*.exe
|
||||||
/PSLauncher*.bak
|
/PS_Launcher*.bak
|
||||||
|
|
||||||
# Sortie de l'installeur Inno Setup
|
# Sortie de l'installeur Inno Setup
|
||||||
/installer/output/
|
/installer/output/
|
||||||
|
|
||||||
|
# WebView2 user data folder créé à côté de l'exe au runtime (cache, cookies,
|
||||||
|
# sync data Edge Chromium). Peut faire plusieurs Mo. Le pattern * couvre les
|
||||||
|
# 2 noms historiques (PSLauncher.exe.WebView2, PS_Launcher.exe.WebView2) et
|
||||||
|
# tout build de test ponctuel avec un autre nom d'exe.
|
||||||
|
*.exe.WebView2/
|
||||||
|
|
||||||
|
# Fichiers de lock Office (Word/PowerPoint) commençant par ~$
|
||||||
|
~$*
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ setlocal
|
|||||||
set "REPO=%~dp0"
|
set "REPO=%~dp0"
|
||||||
echo.
|
echo.
|
||||||
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
|
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
|
||||||
taskkill /F /IM PSLauncher.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
|
||||||
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
||||||
echo.
|
echo.
|
||||||
@@ -22,8 +22,8 @@ if %ERRORLEVEL% NEQ 0 goto :fail
|
|||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo [OK] Les deux exes sont disponibles a la racine du repo :
|
echo [OK] Les deux exes sont disponibles a la racine du repo :
|
||||||
echo %REPO%PSLauncher.exe
|
echo %REPO%PS_Launcher.exe
|
||||||
echo %REPO%PSLauncher.Updater.exe
|
echo %REPO%PS_Launcher.Updater.exe
|
||||||
echo.
|
echo.
|
||||||
pause
|
pause
|
||||||
endlocal & exit /b 0
|
endlocal & exit /b 0
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ setlocal
|
|||||||
set "SCRIPT_DIR=%~dp0"
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
|
||||||
REM Termine les eventuelles instances en cours qui verrouilleraient les .exe
|
REM Termine les eventuelles instances en cours qui verrouilleraient les .exe
|
||||||
taskkill /F /IM PSLauncher.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
|
||||||
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
|
||||||
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%installer\build-installer.ps1" %*
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%installer\build-installer.ps1" %*
|
||||||
set "EXITCODE=%ERRORLEVEL%"
|
set "EXITCODE=%ERRORLEVEL%"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM Build du launcher principal (PSLauncher.exe).
|
REM Build du launcher principal (PS_Launcher.exe).
|
||||||
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe (~78 Mo single-file self-contained)
|
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PS_Launcher.exe (~78 Mo single-file self-contained)
|
||||||
|
|
||||||
setlocal
|
setlocal
|
||||||
set "REPO=%~dp0"
|
set "REPO=%~dp0"
|
||||||
echo.
|
echo.
|
||||||
echo ==^> Termine les eventuelles instances de PSLauncher qui verrouilleraient les .exe
|
echo ==^> Termine les eventuelles instances de PS_Launcher qui verrouilleraient les .exe
|
||||||
taskkill /F /IM PSLauncher.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
|
||||||
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
||||||
echo.
|
echo.
|
||||||
@@ -19,9 +19,9 @@ echo.
|
|||||||
if %EXITCODE% NEQ 0 (
|
if %EXITCODE% NEQ 0 (
|
||||||
echo [ECHEC] Build du launcher echoue avec le code %EXITCODE%.
|
echo [ECHEC] Build du launcher echoue avec le code %EXITCODE%.
|
||||||
) else (
|
) else (
|
||||||
echo [OK] PSLauncher.exe disponible a la racine du repo :
|
echo [OK] PS_Launcher.exe disponible a la racine du repo :
|
||||||
echo %REPO%PSLauncher.exe
|
echo %REPO%PS_Launcher.exe
|
||||||
)
|
)
|
||||||
echo.
|
echo.
|
||||||
pause
|
pause
|
||||||
endlocal & exit /b %EXITCODE%
|
endlocal ^& exit /b %EXITCODE%
|
||||||
|
|||||||
137
build-proserve-zip.bat
Normal file
137
build-proserve-zip.bat
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
@echo off
|
||||||
|
REM ========================================================================
|
||||||
|
REM build-proserve-zip.bat
|
||||||
|
REM Crée le ZIP de release d'une version PROSERVE prêt à uploader sur OVH.
|
||||||
|
REM
|
||||||
|
REM Usage :
|
||||||
|
REM build-proserve-zip.bat 1.5.3
|
||||||
|
REM
|
||||||
|
REM Le script :
|
||||||
|
REM 1. Lit version\proserve-X.Y.Z\ (binaires UE5 + _migrations\ + _report\)
|
||||||
|
REM 2. Compresse en version\proserve-X.Y.Z.zip
|
||||||
|
REM 3. Affiche taille + SHA-256 (à recopier dans le backoffice si tu veux
|
||||||
|
REM eviter le hash automatique côté serveur, sinon laisse le serveur le
|
||||||
|
REM faire au moment du « Hasher + signer »)
|
||||||
|
REM
|
||||||
|
REM Outils :
|
||||||
|
REM - 7-Zip si installé (multi-threading, ~3-5x plus rapide sur 14 Go)
|
||||||
|
REM - PowerShell Compress-Archive en fallback (mono-thread, lent)
|
||||||
|
REM
|
||||||
|
REM Sortie : version\proserve-X.Y.Z.zip à uploader vers
|
||||||
|
REM www/PS_Launcher/builds/proserve-X.Y.Z.zip via SFTP.
|
||||||
|
REM ========================================================================
|
||||||
|
|
||||||
|
setlocal EnableDelayedExpansion
|
||||||
|
|
||||||
|
if "%~1"=="" (
|
||||||
|
echo.
|
||||||
|
echo Usage : build-proserve-zip.bat ^<version^>
|
||||||
|
echo Exemple : build-proserve-zip.bat 1.5.3
|
||||||
|
echo.
|
||||||
|
echo Versions disponibles dans version\ :
|
||||||
|
for /D %%D in ("%~dp0version\proserve-*") do echo %%~nxD
|
||||||
|
echo.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "VERSION=%~1"
|
||||||
|
set "REPO=%~dp0"
|
||||||
|
set "SRC=%REPO%version\proserve-%VERSION%"
|
||||||
|
set "OUT=%REPO%version\proserve-%VERSION%.zip"
|
||||||
|
|
||||||
|
if not exist "%SRC%" (
|
||||||
|
echo [ERREUR] Dossier source introuvable : %SRC%
|
||||||
|
echo Liste des versions disponibles :
|
||||||
|
for /D %%D in ("%REPO%version\proserve-*") do echo %%~nxD
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Sanity-check que les sous-dossiers attendus sont là
|
||||||
|
if not exist "%SRC%\PROSERVE_UE_5_5.exe" (
|
||||||
|
echo [WARN] %SRC%\PROSERVE_UE_5_5.exe absent — c'est normal ? Continuer dans 5s, Ctrl+C pour annuler...
|
||||||
|
timeout /t 5 >nul
|
||||||
|
)
|
||||||
|
if not exist "%SRC%\_migrations" (
|
||||||
|
echo [WARN] %SRC%\_migrations\ absent — pas de migrations DB dans cette release.
|
||||||
|
)
|
||||||
|
if not exist "%SRC%\_report" (
|
||||||
|
echo [WARN] %SRC%\_report\ absent — pas d'outil Report dans cette release.
|
||||||
|
)
|
||||||
|
|
||||||
|
if exist "%OUT%" (
|
||||||
|
echo [INFO] Suppression du zip existant : %OUT%
|
||||||
|
del /Q "%OUT%"
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ----- 7-Zip si disponible -----
|
||||||
|
set "SEVENZIP="
|
||||||
|
if exist "C:\Program Files\7-Zip\7z.exe" set "SEVENZIP=C:\Program Files\7-Zip\7z.exe"
|
||||||
|
if exist "C:\Program Files (x86)\7-Zip\7z.exe" set "SEVENZIP=C:\Program Files (x86)\7-Zip\7z.exe"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================================================
|
||||||
|
echo Compression de proserve-%VERSION% (peut prendre 5-30 min selon la taille)
|
||||||
|
echo Source : %SRC%
|
||||||
|
echo Sortie : %OUT%
|
||||||
|
echo ========================================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
if defined SEVENZIP (
|
||||||
|
echo [INFO] Utilisation de 7-Zip multi-thread (rapide)
|
||||||
|
REM -tzip format ZIP standard ^(compatible avec ce que le launcher attend^)
|
||||||
|
REM -mx=5 compression normale ^(mx=9 ultra serait 2x plus lent pour ~5%% de gain^)
|
||||||
|
REM -mmt=on multi-thread
|
||||||
|
REM -bsp1 progress sur stderr
|
||||||
|
REM Le * en fin de chemin source = on zippe le CONTENU du dossier, pas le dossier lui-meme
|
||||||
|
"%SEVENZIP%" a -tzip -mx=5 -mmt=on -bsp1 "%OUT%" "%SRC%\*"
|
||||||
|
set "EXITCODE=!ERRORLEVEL!"
|
||||||
|
) else (
|
||||||
|
echo [INFO] 7-Zip non trouve, fallback sur PowerShell Compress-Archive ^(lent^).
|
||||||
|
echo Pour accelerer, installe 7-Zip depuis https://www.7-zip.org/
|
||||||
|
echo.
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||||
|
"Compress-Archive -Path '%SRC%\*' -DestinationPath '%OUT%' -CompressionLevel Optimal -Force"
|
||||||
|
set "EXITCODE=!ERRORLEVEL!"
|
||||||
|
)
|
||||||
|
|
||||||
|
if !EXITCODE! NEQ 0 (
|
||||||
|
echo.
|
||||||
|
echo [ECHEC] Compression echouee avec le code !EXITCODE!.
|
||||||
|
exit /b !EXITCODE!
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%OUT%" (
|
||||||
|
echo [ECHEC] Le fichier %OUT% n'a pas ete cree.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================================================
|
||||||
|
echo [OK] Zip cree avec succes
|
||||||
|
echo ========================================================================
|
||||||
|
echo.
|
||||||
|
echo Fichier : %OUT%
|
||||||
|
for %%I in ("%OUT%") do (
|
||||||
|
set "SIZE=%%~zI"
|
||||||
|
echo Taille : !SIZE! octets ^(~%%~zI bytes^)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Calcul du SHA-256 ^(peut prendre 30s-2min selon la taille^)...
|
||||||
|
powershell -NoProfile -Command ^
|
||||||
|
"$h = (Get-FileHash '%OUT%' -Algorithm SHA256).Hash.ToLower(); Write-Host SHA-256 : $h"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================================================
|
||||||
|
echo Prochaines etapes :
|
||||||
|
echo 1. SFTP upload vers : www/PS_Launcher/builds/proserve-%VERSION%.zip
|
||||||
|
echo 2. Backoffice -^> Versions -^> verifier que l'entree v%VERSION% existe
|
||||||
|
echo ^(sinon ajoute-la avec date d'expiration min_license_date^)
|
||||||
|
echo 3. Cliquer ligne v%VERSION% -^> bouton "Hasher cette version"
|
||||||
|
echo ^(ou bouton global "Hasher les versions + signer"^)
|
||||||
|
echo 4. Le manifest est re-signe Ed25519, les clients verront la version
|
||||||
|
echo au prochain "Verifier les MAJ".
|
||||||
|
echo ========================================================================
|
||||||
|
echo.
|
||||||
|
endlocal
|
||||||
|
pause
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM Build de l'updater (PSLauncher.Updater.exe).
|
REM Build de l'updater (PS_Launcher.Updater.exe).
|
||||||
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PSLauncher.Updater.exe (~34 Mo single-file)
|
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PS_Launcher.Updater.exe (~34 Mo single-file)
|
||||||
REM L'updater accompagne PSLauncher.exe — il doit etre dans le meme dossier.
|
REM L'updater accompagne PS_Launcher.exe — il doit etre dans le meme dossier.
|
||||||
|
|
||||||
setlocal
|
setlocal
|
||||||
set "REPO=%~dp0"
|
set "REPO=%~dp0"
|
||||||
echo.
|
echo.
|
||||||
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
|
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
|
||||||
taskkill /F /IM PSLauncher.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
|
||||||
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo ==^> dotnet publish PSLauncher.Updater (Release single-file)
|
echo ==^> dotnet publish PSLauncher.Updater (Release single-file)
|
||||||
echo.
|
echo.
|
||||||
@@ -20,9 +20,9 @@ echo.
|
|||||||
if %EXITCODE% NEQ 0 (
|
if %EXITCODE% NEQ 0 (
|
||||||
echo [ECHEC] Build de l'updater echoue avec le code %EXITCODE%.
|
echo [ECHEC] Build de l'updater echoue avec le code %EXITCODE%.
|
||||||
) else (
|
) else (
|
||||||
echo [OK] PSLauncher.Updater.exe disponible a la racine du repo :
|
echo [OK] PS_Launcher.Updater.exe disponible a la racine du repo :
|
||||||
echo %REPO%PSLauncher.Updater.exe
|
echo %REPO%PS_Launcher.Updater.exe
|
||||||
)
|
)
|
||||||
echo.
|
echo.
|
||||||
pause
|
pause
|
||||||
endlocal & exit /b %EXITCODE%
|
endlocal ^& exit /b %EXITCODE%
|
||||||
|
|||||||
@@ -4,18 +4,18 @@
|
|||||||
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" PSLauncher.iss
|
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" PSLauncher.iss
|
||||||
;
|
;
|
||||||
; Avant compile :
|
; Avant compile :
|
||||||
; 1. Faire `dotnet publish src/PSLauncher.App -c Release` (génère PSLauncher.exe ~77 Mo)
|
; 1. Faire `dotnet publish src/PSLauncher.App -c Release` (génère PS_Launcher.exe ~77 Mo)
|
||||||
; 2. Faire `dotnet publish src/PSLauncher.Updater -c Release` (génère PSLauncher.Updater.exe ~10 Mo)
|
; 2. Faire `dotnet publish src/PSLauncher.Updater -c Release` (génère PS_Launcher.Updater.exe ~10 Mo)
|
||||||
;
|
;
|
||||||
; Le script picore directement les binaires dans bin/Release/.../publish/.
|
; Le script picore directement les binaires dans bin/Release/.../publish/.
|
||||||
|
|
||||||
#define MyAppName "PROSERVE Launcher"
|
#define MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PSLauncher"
|
#define MyAppShortName "PS_Launcher"
|
||||||
#define MyAppVersion "0.8.0"
|
#define MyAppVersion "0.16.0"
|
||||||
#define MyAppPublisher "ASTERION VR"
|
#define MyAppPublisher "ASTERION VR"
|
||||||
#define MyAppURL "https://asterionvr.com"
|
#define MyAppURL "https://asterionvr.com"
|
||||||
#define MyAppExeName "PSLauncher.exe"
|
#define MyAppExeName "PS_Launcher.exe"
|
||||||
#define MyUpdaterExeName "PSLauncher.Updater.exe"
|
#define MyUpdaterExeName "PS_Launcher.Updater.exe"
|
||||||
|
|
||||||
[Setup]
|
[Setup]
|
||||||
; AppId est UNIQUE pour PSLauncher — ne pas changer entre deux versions sinon
|
; AppId est UNIQUE pour PSLauncher — ne pas changer entre deux versions sinon
|
||||||
@@ -29,12 +29,12 @@ AppPublisherURL={#MyAppURL}
|
|||||||
AppSupportURL={#MyAppURL}
|
AppSupportURL={#MyAppURL}
|
||||||
AppUpdatesURL={#MyAppURL}
|
AppUpdatesURL={#MyAppURL}
|
||||||
; Installation user-mode (pas d'admin requis) : le launcher pourra se mettre à jour
|
; Installation user-mode (pas d'admin requis) : le launcher pourra se mettre à jour
|
||||||
; tout seul via PSLauncher.Updater.exe sans déclencher de UAC.
|
; tout seul via PS_Launcher.Updater.exe sans déclencher de UAC.
|
||||||
DefaultDirName={localappdata}\Programs\{#MyAppPublisher}\{#MyAppShortName}
|
DefaultDirName={localappdata}\Programs\{#MyAppPublisher}\{#MyAppShortName}
|
||||||
DefaultGroupName={#MyAppName}
|
DefaultGroupName={#MyAppName}
|
||||||
DisableProgramGroupPage=yes
|
DisableProgramGroupPage=yes
|
||||||
OutputDir=output
|
OutputDir=output
|
||||||
OutputBaseFilename=PSLauncher-Setup-{#MyAppVersion}
|
OutputBaseFilename=PS_Launcher-Setup-{#MyAppVersion}
|
||||||
SetupIconFile=..\src\PSLauncher.App\Resources\favicon.ico
|
SetupIconFile=..\src\PSLauncher.App\Resources\favicon.ico
|
||||||
Compression=lzma2/ultra
|
Compression=lzma2/ultra
|
||||||
SolidCompression=yes
|
SolidCompression=yes
|
||||||
|
|||||||
@@ -19,3 +19,6 @@ ndom_bytes(32)) . PHP_EOL;'hmac_secret = 4343ec086ef4fafb7c0a575e56ac1b83b5eb29c
|
|||||||
Secret2: jwt_secret
|
Secret2: jwt_secret
|
||||||
php -r 'echo "jwt_secret = " . bin2hex(random_bytes(32)) . PHP_EOL;'
|
php -r 'echo "jwt_secret = " . bin2hex(random_bytes(32)) . PHP_EOL;'
|
||||||
jwt_secret = 3bf50765f8af638c143c137e0f1ec539db0bd761a11af602db0a5c000a3872ea
|
jwt_secret = 3bf50765f8af638c143c137e0f1ec539db0bd761a11af602db0a5c000a3872ea
|
||||||
|
|
||||||
|
Clé : PRSRV-WY4S-S485-D2WD-D7YP
|
||||||
|
Clé2 : PRSRV-V652-MPFX-7FJ3-L9QJ
|
||||||
|
|||||||
@@ -53,14 +53,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
'version' => $lver,
|
'version' => $lver,
|
||||||
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
|
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
|
||||||
'download' => [
|
'download' => [
|
||||||
'url' => "{$base}/builds/launcher/PSLauncher-{$lver}.exe",
|
'url' => "{$base}/builds/launcher/PS_Launcher-{$lver}.exe",
|
||||||
'sizeBytes' => 0,
|
'sizeBytes' => 0,
|
||||||
'sha256' => 'REPLACE_AFTER_BUILD',
|
'sha256' => 'REPLACE_AFTER_BUILD',
|
||||||
],
|
],
|
||||||
'releaseNotesUrl' => null,
|
'releaseNotesUrl' => null,
|
||||||
];
|
];
|
||||||
saveManifest($manifestPath, $manifest);
|
saveManifest($manifestPath, $manifest);
|
||||||
$message = "Section launcher mise à jour (v{$lver}). Upload PSLauncher-{$lver}.exe dans builds/launcher/ puis clique « Hasher le launcher + signer ».";
|
$message = "Section launcher mise à jour (v{$lver}). Upload PS_Launcher-{$lver}.exe dans builds/launcher/ puis clique « Hasher le launcher + signer ».";
|
||||||
}
|
}
|
||||||
elseif ($action === 'remove_launcher') {
|
elseif ($action === 'remove_launcher') {
|
||||||
unset($manifest['launcher']);
|
unset($manifest['launcher']);
|
||||||
@@ -130,7 +130,7 @@ Layout::header('Launcher', 'launcher');
|
|||||||
<ol class="muted">
|
<ol class="muted">
|
||||||
<li>Remplis le formulaire <strong>Définir / mettre à jour</strong> ci-dessous (ex : <code>0.6.0</code>).</li>
|
<li>Remplis le formulaire <strong>Définir / mettre à jour</strong> ci-dessous (ex : <code>0.6.0</code>).</li>
|
||||||
<li>Sur ton poste : <code>dotnet publish src\PSLauncher.App -c Release</code> et <code>dotnet publish src\PSLauncher.Updater -c Release</code> (les exes sont copiés à la racine du repo).</li>
|
<li>Sur ton poste : <code>dotnet publish src\PSLauncher.App -c Release</code> et <code>dotnet publish src\PSLauncher.Updater -c Release</code> (les exes sont copiés à la racine du repo).</li>
|
||||||
<li>Renomme la copie : <code>PSLauncher.exe → PSLauncher-X.Y.Z.exe</code>.</li>
|
<li>Renomme la copie : <code>PS_Launcher.exe → PS_Launcher-X.Y.Z.exe</code>.</li>
|
||||||
<li>Upload via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
|
<li>Upload via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
|
||||||
<li>Clique <strong>🔁 Hasher le launcher + signer</strong>.</li>
|
<li>Clique <strong>🔁 Hasher le launcher + signer</strong>.</li>
|
||||||
<li>Les launchers déjà déployés détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
|
<li>Les launchers déjà déployés détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
|
||||||
|
|||||||
@@ -78,7 +78,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
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]);
|
||||||
$message = "Machines libérées pour la license #{$id}.";
|
$message = "Toutes les machines libérées pour la license #{$id}.";
|
||||||
|
}
|
||||||
|
elseif ($action === 'remove_machine') {
|
||||||
|
$licenseId = (int)($_POST['license_id'] ?? 0);
|
||||||
|
$machineId = trim((string)($_POST['machine_id'] ?? ''));
|
||||||
|
if ($licenseId <= 0 || $machineId === '') {
|
||||||
|
throw new Exception('license_id et machine_id requis');
|
||||||
|
}
|
||||||
|
$stmt = $db->prepare('DELETE FROM license_machines WHERE license_id = ? AND machine_id = ?');
|
||||||
|
$stmt->execute([$licenseId, $machineId]);
|
||||||
|
$count = $stmt->rowCount();
|
||||||
|
$shortId = substr($machineId, 0, 12);
|
||||||
|
$message = $count > 0
|
||||||
|
? "Machine {$shortId}… libérée pour la license #{$licenseId}."
|
||||||
|
: "Aucune machine correspondante trouvée pour license #{$licenseId}.";
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$message = $e->getMessage();
|
$message = $e->getMessage();
|
||||||
@@ -93,6 +107,18 @@ $licenses = $db->query(
|
|||||||
ORDER BY l.id DESC'
|
ORDER BY l.id DESC'
|
||||||
)->fetchAll();
|
)->fetchAll();
|
||||||
|
|
||||||
|
// Pré-fetch toutes les machines de toutes les licenses en une seule requête,
|
||||||
|
// indexées par license_id pour l'affichage par-row sans N+1.
|
||||||
|
$allMachines = $db->query(
|
||||||
|
'SELECT license_id, machine_id, machine_label, first_seen, last_seen
|
||||||
|
FROM license_machines
|
||||||
|
ORDER BY last_seen DESC'
|
||||||
|
)->fetchAll();
|
||||||
|
$machinesByLicense = [];
|
||||||
|
foreach ($allMachines as $m) {
|
||||||
|
$machinesByLicense[(int)$m['license_id']][] = $m;
|
||||||
|
}
|
||||||
|
|
||||||
Layout::header('Licenses', 'licenses');
|
Layout::header('Licenses', 'licenses');
|
||||||
?>
|
?>
|
||||||
<h1>Licenses</h1>
|
<h1>Licenses</h1>
|
||||||
@@ -165,7 +191,17 @@ Layout::header('Licenses', 'licenses');
|
|||||||
</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>
|
||||||
<td><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></td>
|
<td><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></td>
|
||||||
<td><?= $l['machines_count'] ?> / <?= $l['max_machines'] ?></td>
|
<td>
|
||||||
|
<?php if ($l['machines_count'] > 0): ?>
|
||||||
|
<a href="#" onclick="document.getElementById('machines-<?= $l['id'] ?>').open = !document.getElementById('machines-<?= $l['id'] ?>').open; return false;"
|
||||||
|
style="color: var(--text); text-decoration: none; cursor: pointer;"
|
||||||
|
title="Cliquer pour voir les machines">
|
||||||
|
<strong><?= $l['machines_count'] ?></strong> / <?= $l['max_machines'] ?> ▾
|
||||||
|
</a>
|
||||||
|
<?php else: ?>
|
||||||
|
0 / <?= $l['max_machines'] ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</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;">
|
<details style="display: inline-block; margin: 0 4px;">
|
||||||
@@ -179,11 +215,11 @@ Layout::header('Licenses', 'licenses');
|
|||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
<?php if ($l['machines_count'] > 0): ?>
|
<?php if ($l['machines_count'] > 0): ?>
|
||||||
<form method="post" style="display:inline" onsubmit="return confirm('Libérer toutes les machines de cette license ?')">
|
<form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="reset_machines">
|
<input type="hidden" name="action" value="reset_machines">
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
<button class="btn btn-secondary" type="submit" title="Libère les slots machines occupés">Libérer machines</button>
|
<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>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!$isRevoked): ?>
|
<?php if (!$isRevoked): ?>
|
||||||
@@ -203,6 +239,73 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// Sous-row dépliable : liste des machines avec remove individuel.
|
||||||
|
// Le <details> est piloté par le clic sur la cellule "X / Y" ci-dessus.
|
||||||
|
$machines = $machinesByLicense[(int)$l['id']] ?? [];
|
||||||
|
if (!empty($machines)):
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" style="padding: 0;">
|
||||||
|
<details id="machines-<?= $l['id'] ?>" style="margin: 0;">
|
||||||
|
<summary style="display: none;"></summary>
|
||||||
|
<div style="background: rgba(0,0,0,0.2); padding: 12px 16px; border-top: 1px solid var(--border);">
|
||||||
|
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">
|
||||||
|
Machines actives sur la license #<?= $l['id'] ?> (<?= htmlspecialchars($l['owner_name']) ?>) :
|
||||||
|
</div>
|
||||||
|
<table style="width: 100%; font-size: 12px; margin: 0;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 280px;">Machine ID (SHA-256)</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th style="width: 130px;">1ère activation</th>
|
||||||
|
<th style="width: 130px;">Dernière vue</th>
|
||||||
|
<th style="width: 110px; text-align:right;">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($machines as $m):
|
||||||
|
$mid = $m['machine_id'];
|
||||||
|
$isStale = strtotime($m['last_seen']) < time() - 86400 * 30;
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code title="<?= htmlspecialchars($mid) ?>"
|
||||||
|
style="font-family: 'Cascadia Code', Consolas, monospace;">
|
||||||
|
<?= htmlspecialchars(substr($mid, 0, 16)) ?>…<?= htmlspecialchars(substr($mid, -8)) ?>
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td><?= htmlspecialchars($m['machine_label'] ?? '—') ?></td>
|
||||||
|
<td class="muted"><?= date('d/m/Y H:i', strtotime($m['first_seen'])) ?></td>
|
||||||
|
<td class="muted">
|
||||||
|
<?= date('d/m/Y H:i', strtotime($m['last_seen'])) ?>
|
||||||
|
<?php if ($isStale): ?>
|
||||||
|
<span class="badge badge-warning" title="Pas vu depuis > 30 jours, candidat à libération">stale</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<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.')">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="remove_machine">
|
||||||
|
<input type="hidden" name="license_id" value="<?= $l['id'] ?>">
|
||||||
|
<input type="hidden" name="machine_id" value="<?= htmlspecialchars($mid) ?>">
|
||||||
|
<button class="btn btn-danger" type="submit"
|
||||||
|
style="font-size: 11px; padding: 4px 10px;">
|
||||||
|
Libérer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php if (empty($licenses)): ?>
|
<?php if (empty($licenses)): ?>
|
||||||
<tr><td colspan="7" class="muted" style="text-align:center; padding: 32px;">Aucune license émise.</td></tr>
|
<tr><td colspan="7" class="muted" style="text-align:center; padding: 32px;">Aucune license émise.</td></tr>
|
||||||
|
|||||||
32
server/releasenotes/1.5.3.md
Normal file
32
server/releasenotes/1.5.3.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Proserve v1.5.3
|
||||||
|
|
||||||
|
*Release date: May 3, 2026*
|
||||||
|
|
||||||
|
## What's new
|
||||||
|
|
||||||
|
- **New MM CQB scenario** (close-quarters combat in an urban environment)
|
||||||
|
- **Persistent weapon selection** for the entire PROSERVE session — the default weapon stored in the database is only restored when PROSERVE is restarted
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- Refreshed **statistics icon** in the UI
|
||||||
|
- **Quick calibration** in the Calibration Scene is now restricted to users equipped with a VR controller (works around a bug specific to Firefighter mode)
|
||||||
|
|
||||||
|
## Bug fixes
|
||||||
|
|
||||||
|
- Fixed a bug in the **instructor view** affecting the player description
|
||||||
|
- Miscellaneous stability fixes
|
||||||
|
|
||||||
|
## Database migration
|
||||||
|
|
||||||
|
This version automatically applies 3 database migrations through the launcher:
|
||||||
|
|
||||||
|
- `0001_Init.sql` — baseline schema (no-op if the database already exists)
|
||||||
|
- `0002_SessionType_ps_1.4.5.sql` — adds the **LongRange** session type and renames type 6 to **Rescue**
|
||||||
|
- `0003_UserSize_ps_1.5.0.sql` — adds the `size` column (in cm) to the `users` table
|
||||||
|
|
||||||
|
No manual action required. If XAMPP is offline at install time, you can replay the migrations later via **Settings → Advanced → Database → Replay migrations**.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Deployment of the local reporting tool (`/ProserveReport/`) is synchronized with PROSERVE: a timestamped backup of the previous version is kept under `C:\xampp\htdocs\` and a revert is available via **Settings → Advanced → Report Tool**.
|
||||||
@@ -16,7 +16,9 @@ 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.Migrations;
|
||||||
using PSLauncher.Core.Process;
|
using PSLauncher.Core.Process;
|
||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
using PSLauncher.Core.Updates;
|
using PSLauncher.Core.Updates;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -162,6 +164,24 @@ public partial class App : Application
|
|||||||
|
|
||||||
services.AddSingleton<IToastService, ToastService>();
|
services.AddSingleton<IToastService, ToastService>();
|
||||||
|
|
||||||
|
// Migration DB MySQL : appliquée juste après extraction d'un build PROSERVE
|
||||||
|
// si le ZIP contient un sous-répertoire _migrations/. La config DB et le
|
||||||
|
// password (DPAPI) sont lus à la demande pour refléter les changements
|
||||||
|
// utilisateur dans Settings sans recréer le service.
|
||||||
|
services.AddSingleton<IDatabaseMigrationService>(sp =>
|
||||||
|
new DatabaseMigrationService(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().Database,
|
||||||
|
passwordProvider: () => DatabasePasswordProtector.Unprotect(
|
||||||
|
sp.GetRequiredService<LocalConfig>().Database.EncryptedPassword),
|
||||||
|
sp.GetRequiredService<ILogger<DatabaseMigrationService>>()));
|
||||||
|
|
||||||
|
// Déploiement de l'outil Report (page web XAMPP) à l'install d'une
|
||||||
|
// nouvelle version : copie atomique de _report/ → C:\xampp\htdocs\ProserveReport\
|
||||||
|
services.AddSingleton<IReportToolDeployer>(sp =>
|
||||||
|
new ReportToolDeployer(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().ReportTool,
|
||||||
|
sp.GetRequiredService<ILogger<ReportToolDeployer>>()));
|
||||||
|
|
||||||
services.AddTransient<SettingsViewModel>();
|
services.AddTransient<SettingsViewModel>();
|
||||||
services.AddSingleton<MainViewModel>();
|
services.AddSingleton<MainViewModel>();
|
||||||
services.AddSingleton<MainWindow>();
|
services.AddSingleton<MainWindow>();
|
||||||
|
|||||||
@@ -10,14 +10,14 @@
|
|||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<ApplicationIcon>Resources\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>Resources\favicon.ico</ApplicationIcon>
|
||||||
<AssemblyName>PSLauncher</AssemblyName>
|
<AssemblyName>PS_Launcher</AssemblyName>
|
||||||
<Company>ASTERION VR</Company>
|
<Company>ASTERION VR</Company>
|
||||||
<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.9.0</Version>
|
<Version>0.16.0</Version>
|
||||||
<AssemblyVersion>0.9.0.0</AssemblyVersion>
|
<AssemblyVersion>0.16.0.0</AssemblyVersion>
|
||||||
<FileVersion>0.9.0.0</FileVersion>
|
<FileVersion>0.16.0.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>
|
||||||
@@ -40,6 +40,11 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||||
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
|
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
|
||||||
|
<!-- WebView2 (Edge Chromium) pour les onglets Report et Documentation.
|
||||||
|
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
|
||||||
|
et on aiguille l'user vers https://go.microsoft.com/fwlink/p/?LinkId=2124703 -->
|
||||||
|
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2792.45" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -55,7 +60,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Après chaque `dotnet publish -c Release`, copie le single-file exe à la racine du repo
|
<!-- Après chaque `dotnet publish -c Release`, copie le single-file exe à la racine du repo
|
||||||
(C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe). ContinueOnError pour ne pas casser le
|
(C:\ASTERION\GIT\PS_Launcher\PS_Launcher.exe). ContinueOnError pour ne pas casser le
|
||||||
publish si une instance du launcher tourne et verrouille la cible — l'opérateur peut
|
publish si une instance du launcher tourne et verrouille la cible — l'opérateur peut
|
||||||
killer et réessayer, ou simplement utiliser le binaire depuis bin\Release\... -->
|
killer et réessayer, ou simplement utiliser le binaire depuis bin\Release\... -->
|
||||||
<Target Name="CopyPublishedToRepoRoot" AfterTargets="Publish"
|
<Target Name="CopyPublishedToRepoRoot" AfterTargets="Publish"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace PSLauncher.App.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inverse de <see cref="System.Windows.Controls.BooleanToVisibilityConverter"/> : true → Collapsed, false → Visible.
|
||||||
|
/// Utile pour afficher un placeholder quand un binding bool est false (ex. "DocumentationUrl est vide").
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InverseBoolToVisibilityConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is bool b && b ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is Visibility v && v != Visibility.Visible;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns:res="clr-namespace:PSLauncher.App.Resources">
|
xmlns:res="clr-namespace:PSLauncher.App.Resources">
|
||||||
|
|
||||||
<BooleanToVisibilityConverter x:Key="BoolToVisibility" />
|
<BooleanToVisibilityConverter x:Key="BoolToVisibility" />
|
||||||
|
<res:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisibility" />
|
||||||
|
|
||||||
<!-- Custom font Pirulen pour les titres de marque -->
|
<!-- Custom font Pirulen pour les titres de marque -->
|
||||||
<FontFamily x:Key="Font.Brand">pack://application:,,,/Resources/#Pirulen</FontFamily>
|
<FontFamily x:Key="Font.Brand">pack://application:,,,/Resources/#Pirulen</FontFamily>
|
||||||
@@ -72,6 +73,47 @@
|
|||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
NavButton : item de la sidebar de droite (Library / Report / Documentation).
|
||||||
|
Plein-largeur, alignement à gauche, pas de bordure, surligné quand actif
|
||||||
|
via Tag (bool) qui est lié à IsLibrary/IsReport/IsDocumentation.
|
||||||
|
-->
|
||||||
|
<Style x:Key="NavButton" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Secondary}" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Padding" Value="20,12" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderThickness="3,0,0,0"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Left" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#1A1F2A" />
|
||||||
|
</Trigger>
|
||||||
|
<!-- "Tag" est utilisé comme flag actif (IsLibrary/IsReport/IsDocumentation) -->
|
||||||
|
<DataTrigger Binding="{Binding RelativeSource={RelativeSource Self}, Path=Tag}" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#1F2937" />
|
||||||
|
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Brush.Accent}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
</DataTrigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style x:Key="PrimaryButton" TargetType="Button">
|
<Style x:Key="PrimaryButton" TargetType="Button">
|
||||||
<Setter Property="Background" Value="{StaticResource Brush.Play}" />
|
<Setter Property="Background" Value="{StaticResource Brush.Play}" />
|
||||||
<Setter Property="Foreground" Value="White" />
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
|||||||
@@ -14,12 +14,20 @@ using PSLauncher.Core.Installations;
|
|||||||
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.Migrations;
|
||||||
using PSLauncher.Core.Process;
|
using PSLauncher.Core.Process;
|
||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
using PSLauncher.Core.Updates;
|
using PSLauncher.Core.Updates;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.App.ViewModels;
|
namespace PSLauncher.App.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Onglets affichés dans la sidebar droite du launcher. Chacun correspond à un
|
||||||
|
/// bloc de contenu visible/caché dans la fenêtre principale.
|
||||||
|
/// </summary>
|
||||||
|
public enum LauncherPage { Library, Report, Documentation }
|
||||||
|
|
||||||
public sealed partial class MainViewModel : ObservableObject
|
public sealed partial class MainViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IInstallationRegistry _registry;
|
private readonly IInstallationRegistry _registry;
|
||||||
@@ -33,6 +41,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private readonly ILicenseService _licenseService;
|
private readonly ILicenseService _licenseService;
|
||||||
private readonly ILauncherSelfUpdater _selfUpdater;
|
private readonly ILauncherSelfUpdater _selfUpdater;
|
||||||
private readonly IToastService _toastService;
|
private readonly IToastService _toastService;
|
||||||
|
private readonly IDatabaseMigrationService _migrationService;
|
||||||
|
private readonly IReportToolDeployer _reportDeployer;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly ILogger<MainViewModel> _logger;
|
private readonly ILogger<MainViewModel> _logger;
|
||||||
|
|
||||||
@@ -66,6 +76,39 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
[ObservableProperty] private double _progressPercent;
|
[ObservableProperty] private double _progressPercent;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Onglet actuellement actif dans la sidebar gauche. La UI montre le bon bloc
|
||||||
|
/// via les converters IsLibrary / IsReport / IsDocumentation. Default = Library
|
||||||
|
/// pour que le launcher s'ouvre directement sur l'écran principal.
|
||||||
|
/// On notifie aussi FooterVisibility/FooterText : le footer (barre de DL,
|
||||||
|
/// status d'install) appartient à la page Library uniquement — il n'a aucun
|
||||||
|
/// sens visuel sur Report/Documentation qui sont des WebView pleine page.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsLibrary))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsReport))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsDocumentation))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||||
|
private LauncherPage _currentPage = LauncherPage.Library;
|
||||||
|
|
||||||
|
public bool IsLibrary => CurrentPage == LauncherPage.Library;
|
||||||
|
public bool IsReport => CurrentPage == LauncherPage.Report;
|
||||||
|
public bool IsDocumentation => CurrentPage == LauncherPage.Documentation;
|
||||||
|
|
||||||
|
/// <summary>URL résolue pour le panneau Reports. WebView2.Source attend un Uri.</summary>
|
||||||
|
public Uri? ReportUri => TryParseUri(_config.ReportUrl);
|
||||||
|
/// <summary>URL résolue pour le panneau Documentation. Null si non configurée.</summary>
|
||||||
|
public Uri? DocumentationUri => TryParseUri(_config.DocumentationUrl);
|
||||||
|
public bool HasDocumentationUrl => DocumentationUri is not null;
|
||||||
|
|
||||||
|
private static Uri? TryParseUri(string? s)
|
||||||
|
=> !string.IsNullOrWhiteSpace(s) && Uri.TryCreate(s, UriKind.Absolute, out var u) ? u : null;
|
||||||
|
|
||||||
|
[RelayCommand] private void NavigateLibrary() => CurrentPage = LauncherPage.Library;
|
||||||
|
[RelayCommand] private void NavigateReport() => CurrentPage = LauncherPage.Report;
|
||||||
|
[RelayCommand] private void NavigateDocumentation() => CurrentPage = LauncherPage.Documentation;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(FooterText))]
|
[NotifyPropertyChangedFor(nameof(FooterText))]
|
||||||
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
[NotifyPropertyChangedFor(nameof(FooterVisibility))]
|
||||||
@@ -154,8 +197,16 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
FeaturedVersion is null && OtherVersions.Count == 0
|
FeaturedVersion is null && OtherVersions.Count == 0
|
||||||
? Visibility.Visible : Visibility.Collapsed;
|
? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Footer visible UNIQUEMENT sur la page Library : c'est elle qui héberge
|
||||||
|
/// les téléchargements et installs, donc le seul contexte où la barre
|
||||||
|
/// de progression a du sens. Sur Report/Documentation (WebView pleine page),
|
||||||
|
/// le footer disparaît même si IsBusy ou StatusMessage non vide — l'opération
|
||||||
|
/// continue en arrière-plan, le user retrouve son état en revenant sur Library.
|
||||||
|
/// </summary>
|
||||||
public Visibility FooterVisibility =>
|
public Visibility FooterVisibility =>
|
||||||
IsBusy || !string.IsNullOrEmpty(StatusMessage) ? Visibility.Visible : Visibility.Collapsed;
|
IsLibrary && (IsBusy || !string.IsNullOrEmpty(StatusMessage))
|
||||||
|
? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
public string FooterText =>
|
public string FooterText =>
|
||||||
!string.IsNullOrEmpty(ProgressDetail) ? ProgressDetail :
|
!string.IsNullOrEmpty(ProgressDetail) ? ProgressDetail :
|
||||||
@@ -173,6 +224,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
ILicenseService licenseService,
|
ILicenseService licenseService,
|
||||||
ILauncherSelfUpdater selfUpdater,
|
ILauncherSelfUpdater selfUpdater,
|
||||||
IToastService toastService,
|
IToastService toastService,
|
||||||
|
IDatabaseMigrationService migrationService,
|
||||||
|
IReportToolDeployer reportDeployer,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
ILogger<MainViewModel> logger)
|
ILogger<MainViewModel> logger)
|
||||||
{
|
{
|
||||||
@@ -187,6 +240,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_licenseService = licenseService;
|
_licenseService = licenseService;
|
||||||
_selfUpdater = selfUpdater;
|
_selfUpdater = selfUpdater;
|
||||||
_toastService = toastService;
|
_toastService = toastService;
|
||||||
|
_migrationService = migrationService;
|
||||||
|
_reportDeployer = reportDeployer;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
@@ -383,7 +438,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Self-update failed");
|
_logger.LogError(ex, "Self-update failed");
|
||||||
MessageBox.Show(Strings.MsgSelfUpdateFailed(ex.Message),
|
ThemedMessageBox.Show(Strings.MsgSelfUpdateFailed(ex.Message),
|
||||||
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
StatusMessage = Strings.StatusSelfUpdateError(ex.Message);
|
StatusMessage = Strings.StatusSelfUpdateError(ex.Message);
|
||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
@@ -463,7 +518,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private void CancelDownload()
|
private void CancelDownload()
|
||||||
{
|
{
|
||||||
if (_activeDownloadCts is null || _activeRow is null) return;
|
if (_activeDownloadCts is null || _activeRow is null) return;
|
||||||
var confirm = MessageBox.Show(
|
var confirm = ThemedMessageBox.Show(
|
||||||
Strings.MsgCancelDownloadConfirm(_activeRow.Version),
|
Strings.MsgCancelDownloadConfirm(_activeRow.Version),
|
||||||
Strings.MsgBoxCancelDl,
|
Strings.MsgBoxCancelDl,
|
||||||
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||||||
@@ -480,7 +535,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
// Si un DL est actif sur cette même row, on doit d'abord l'annuler proprement.
|
// Si un DL est actif sur cette même row, on doit d'abord l'annuler proprement.
|
||||||
var st = _downloadManager.GetResumableState(row.Version);
|
var st = _downloadManager.GetResumableState(row.Version);
|
||||||
var sizeStr = FormatSize(st?.DownloadedBytes ?? 0);
|
var sizeStr = FormatSize(st?.DownloadedBytes ?? 0);
|
||||||
var confirm = MessageBox.Show(
|
var confirm = ThemedMessageBox.Show(
|
||||||
Strings.MsgRestartDownloadConfirm(row.Version, sizeStr),
|
Strings.MsgRestartDownloadConfirm(row.Version, sizeStr),
|
||||||
Strings.MsgBoxRestartDl,
|
Strings.MsgBoxRestartDl,
|
||||||
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
|
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
|
||||||
@@ -550,7 +605,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Launch failed");
|
_logger.LogError(ex, "Launch failed");
|
||||||
MessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
|
ThemedMessageBox.Show(Strings.MsgLaunchFailed(ex.Message),
|
||||||
Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error);
|
Strings.MsgBoxLaunchError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,7 +613,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private async Task InstallVersionAsync(VersionRowViewModel row)
|
private async Task InstallVersionAsync(VersionRowViewModel row)
|
||||||
{
|
{
|
||||||
if (row.Remote is null) return;
|
if (row.Remote is null) return;
|
||||||
if (IsBusy) { MessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }
|
if (IsBusy) { ThemedMessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }
|
||||||
|
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
_activeRow = row;
|
_activeRow = row;
|
||||||
@@ -655,6 +710,89 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
try { File.Delete(zipPath); } catch { /* non critique */ }
|
try { File.Delete(zipPath); } catch { /* non critique */ }
|
||||||
|
|
||||||
|
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
|
||||||
|
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
|
||||||
|
if (_config.Database.AutoApplyMigrations)
|
||||||
|
{
|
||||||
|
StatusMessage = Strings.StatusApplyingMigrations(row.Version);
|
||||||
|
ProgressDetail = null;
|
||||||
|
ProgressPercent = 0;
|
||||||
|
var migProgress = new Progress<MigrationProgress>(mp =>
|
||||||
|
{
|
||||||
|
if (mp.Total > 0)
|
||||||
|
{
|
||||||
|
var pct = (double)mp.Done / mp.Total * 100.0;
|
||||||
|
ProgressPercent = pct;
|
||||||
|
row.ProgressPercent = pct;
|
||||||
|
}
|
||||||
|
var label = string.IsNullOrEmpty(mp.CurrentFilename)
|
||||||
|
? Strings.StatusApplyingMigrations(row.Version)
|
||||||
|
: Strings.ProgressMigration(mp.Done + 1, mp.Total, mp.CurrentFilename);
|
||||||
|
ProgressDetail = label;
|
||||||
|
row.ProgressDetail = label;
|
||||||
|
});
|
||||||
|
var migResult = await _migrationService.ApplyMigrationsAsync(target, migProgress, ct);
|
||||||
|
if (!migResult.AllSucceeded)
|
||||||
|
{
|
||||||
|
// L'install a réussi côté fichiers ; on garde la version installée mais on
|
||||||
|
// notifie l'utilisateur que la DB n'a pas été migrée — la prochaine relance
|
||||||
|
// de l'install (ou un Settings → "Rejouer migrations") tentera à nouveau.
|
||||||
|
_logger.LogError("DB migration failed for v{Version}: {Detail}", row.Version, migResult.FailureMessage);
|
||||||
|
ThemedMessageBox.Show(
|
||||||
|
Strings.MsgMigrationFailed(migResult.FailureMessage ?? "?"),
|
||||||
|
Strings.MsgBoxMigrationFailed,
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
else if (migResult.AppliedCount > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Applied {N} DB migration(s) for v{Version}", migResult.AppliedCount, row.Version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) Déploiement de l'outil Report (page web XAMPP).
|
||||||
|
// Cherche _report/ dans le ZIP extrait et copie vers htdocs en
|
||||||
|
// atomic rename. Skip silencieux si _report/ absent du ZIP.
|
||||||
|
if (_config.ReportTool.AutoDeploy)
|
||||||
|
{
|
||||||
|
StatusMessage = Strings.StatusDeployingReport(row.Version);
|
||||||
|
ProgressDetail = null;
|
||||||
|
ProgressPercent = 0;
|
||||||
|
var rdProgress = new Progress<DeployProgress>(dp =>
|
||||||
|
{
|
||||||
|
if (dp.FilesTotal > 0)
|
||||||
|
{
|
||||||
|
var pct = (double)dp.FilesDone / dp.FilesTotal * 100.0;
|
||||||
|
ProgressPercent = pct;
|
||||||
|
row.ProgressPercent = pct;
|
||||||
|
}
|
||||||
|
var label = Strings.ProgressReportDeploy(dp.FilesDone, dp.FilesTotal, dp.CurrentFilename);
|
||||||
|
ProgressDetail = label;
|
||||||
|
row.ProgressDetail = label;
|
||||||
|
});
|
||||||
|
var rdResult = await _reportDeployer.DeployAsync(target, rdProgress, ct);
|
||||||
|
switch (rdResult.Status)
|
||||||
|
{
|
||||||
|
case DeployStatus.Deployed:
|
||||||
|
_logger.LogInformation("Report tool deployed ({N} files) for v{Version}", rdResult.FilesDeployed, row.Version);
|
||||||
|
break;
|
||||||
|
case DeployStatus.SkippedNoSourceFolder:
|
||||||
|
_logger.LogInformation("No _report/ in v{Version} ZIP, report tool unchanged", row.Version);
|
||||||
|
break;
|
||||||
|
case DeployStatus.XamppNotFound:
|
||||||
|
ThemedMessageBox.Show(
|
||||||
|
Strings.MsgReportXamppNotFound(_config.ReportTool.HtdocsRoot),
|
||||||
|
Strings.MsgBoxReportDeployFailed,
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
break;
|
||||||
|
case DeployStatus.Failed:
|
||||||
|
ThemedMessageBox.Show(
|
||||||
|
Strings.MsgReportDeployFailed(rdResult.ErrorMessage ?? "?"),
|
||||||
|
Strings.MsgBoxReportDeployFailed,
|
||||||
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
|
||||||
ProgressDetail = null;
|
ProgressDetail = null;
|
||||||
ProgressPercent = 0;
|
ProgressPercent = 0;
|
||||||
@@ -680,7 +818,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
row.State = VersionRowState.AvailableIdle;
|
row.State = VersionRowState.AvailableIdle;
|
||||||
row.ProgressDetail = null;
|
row.ProgressDetail = null;
|
||||||
row.ProgressPercent = 0;
|
row.ProgressPercent = 0;
|
||||||
MessageBox.Show(
|
ThemedMessageBox.Show(
|
||||||
Strings.MsgInstallFailed(ex.Message),
|
Strings.MsgInstallFailed(ex.Message),
|
||||||
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
StatusMessage = Strings.StatusError(ex.Message);
|
StatusMessage = Strings.StatusError(ex.Message);
|
||||||
@@ -703,14 +841,14 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
if (row.Installed is null) return;
|
if (row.Installed is null) return;
|
||||||
|
|
||||||
var sizeStr = FormatSize(row.SizeBytes);
|
var sizeStr = FormatSize(row.SizeBytes);
|
||||||
var confirm = MessageBox.Show(
|
var confirm = ThemedMessageBox.Show(
|
||||||
Strings.MsgUninstallConfirm(row.Version, row.FolderPath, sizeStr),
|
Strings.MsgUninstallConfirm(row.Version, row.FolderPath, sizeStr),
|
||||||
Strings.MsgBoxConfirm,
|
Strings.MsgBoxConfirm,
|
||||||
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
|
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
|
||||||
|
|
||||||
if (confirm != MessageBoxResult.Yes) return;
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
if (IsBusy) { MessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }
|
if (IsBusy) { ThemedMessageBox.Show(Strings.MsgBusy, Strings.MsgBoxPatience, MessageBoxButton.OK, MessageBoxImage.Information); return; }
|
||||||
|
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
row.State = VersionRowState.Uninstalling;
|
row.State = VersionRowState.Uninstalling;
|
||||||
@@ -725,7 +863,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
_logger.LogError(ex, "Uninstall failed for {Version}", row.Version);
|
_logger.LogError(ex, "Uninstall failed for {Version}", row.Version);
|
||||||
row.State = VersionRowState.InstalledIdle;
|
row.State = VersionRowState.InstalledIdle;
|
||||||
MessageBox.Show(Strings.MsgUninstallFailed(ex.Message), Strings.MsgBoxError,
|
ThemedMessageBox.Show(Strings.MsgUninstallFailed(ex.Message), Strings.MsgBoxError,
|
||||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
StatusMessage = Strings.StatusError(ex.Message);
|
StatusMessage = Strings.StatusError(ex.Message);
|
||||||
}
|
}
|
||||||
@@ -740,7 +878,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
var url = row.Remote?.ReleaseNotesUrl;
|
var url = row.Remote?.ReleaseNotesUrl;
|
||||||
if (string.IsNullOrEmpty(url))
|
if (string.IsNullOrEmpty(url))
|
||||||
{
|
{
|
||||||
MessageBox.Show(Strings.MsgNoReleaseNotes,
|
ThemedMessageBox.Show(Strings.MsgNoReleaseNotes,
|
||||||
Strings.MsgBoxReleaseNotes, MessageBoxButton.OK, MessageBoxImage.Information);
|
Strings.MsgBoxReleaseNotes, MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -749,7 +887,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch release notes");
|
_logger.LogWarning(ex, "Failed to fetch release notes");
|
||||||
MessageBox.Show(Strings.MsgReleaseNotesFetchFailed(ex.Message),
|
ThemedMessageBox.Show(Strings.MsgReleaseNotesFetchFailed(ex.Message),
|
||||||
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Warning);
|
Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ using System.Windows;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.App.Views;
|
||||||
using PSLauncher.Core.Configuration;
|
using PSLauncher.Core.Configuration;
|
||||||
using PSLauncher.Core.Downloads;
|
using PSLauncher.Core.Downloads;
|
||||||
|
using PSLauncher.Core.Installations;
|
||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
using PSLauncher.Core.Localization;
|
using PSLauncher.Core.Localization;
|
||||||
|
using PSLauncher.Core.Migrations;
|
||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.App.ViewModels;
|
namespace PSLauncher.App.ViewModels;
|
||||||
@@ -27,6 +31,9 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
private readonly LocalConfig _config;
|
private readonly LocalConfig _config;
|
||||||
private readonly ILicenseService _licenseService;
|
private readonly ILicenseService _licenseService;
|
||||||
private readonly IDownloadStateStore _downloadStore;
|
private readonly IDownloadStateStore _downloadStore;
|
||||||
|
private readonly IDatabaseMigrationService _migrationService;
|
||||||
|
private readonly IReportToolDeployer _reportDeployer;
|
||||||
|
private readonly IInstallationRegistry _registry;
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly ILogger<SettingsViewModel> _logger;
|
private readonly ILogger<SettingsViewModel> _logger;
|
||||||
|
|
||||||
@@ -38,6 +45,31 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string _serverBaseUrl;
|
[ObservableProperty] private string _serverBaseUrl;
|
||||||
[ObservableProperty] private string _installRoot;
|
[ObservableProperty] private string _installRoot;
|
||||||
[ObservableProperty] private string _language;
|
[ObservableProperty] private string _language;
|
||||||
|
[ObservableProperty] private string _reportUrl;
|
||||||
|
[ObservableProperty] private string _documentationUrl;
|
||||||
|
|
||||||
|
// ----- DB Migration settings -----
|
||||||
|
[ObservableProperty] private string _dbHost;
|
||||||
|
[ObservableProperty] private uint _dbPort;
|
||||||
|
[ObservableProperty] private string _dbUser;
|
||||||
|
[ObservableProperty] private string _dbPassword;
|
||||||
|
[ObservableProperty] private string _dbName;
|
||||||
|
[ObservableProperty] private bool _dbAutoApplyMigrations;
|
||||||
|
[ObservableProperty] private string? _dbConnectionStatus;
|
||||||
|
[ObservableProperty] private bool _isTestingDb;
|
||||||
|
[ObservableProperty] private string? _migrationsReplayStatus;
|
||||||
|
[ObservableProperty] private bool _isReplayingMigrations;
|
||||||
|
|
||||||
|
// ----- Report Tool deploy settings -----
|
||||||
|
[ObservableProperty] private string _reportHtdocsRoot;
|
||||||
|
[ObservableProperty] private string _reportFolderName;
|
||||||
|
[ObservableProperty] private bool _reportAutoDeploy;
|
||||||
|
[ObservableProperty] private int _reportMaxBackups;
|
||||||
|
[ObservableProperty] private string? _reportDeployStatus;
|
||||||
|
[ObservableProperty] private bool _isDeployingReport;
|
||||||
|
public System.Collections.ObjectModel.ObservableCollection<ReportBackupViewModel> ReportBackups { get; }
|
||||||
|
= new();
|
||||||
|
public bool HasReportBackups => ReportBackups.Count > 0;
|
||||||
|
|
||||||
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
|
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
|
||||||
PSLauncher.Core.Localization.Strings.Available
|
PSLauncher.Core.Localization.Strings.Available
|
||||||
@@ -105,6 +137,9 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
LocalConfig config,
|
LocalConfig config,
|
||||||
ILicenseService licenseService,
|
ILicenseService licenseService,
|
||||||
IDownloadStateStore downloadStore,
|
IDownloadStateStore downloadStore,
|
||||||
|
IDatabaseMigrationService migrationService,
|
||||||
|
IReportToolDeployer reportDeployer,
|
||||||
|
IInstallationRegistry registry,
|
||||||
HttpClient http,
|
HttpClient http,
|
||||||
ILogger<SettingsViewModel> logger)
|
ILogger<SettingsViewModel> logger)
|
||||||
{
|
{
|
||||||
@@ -112,12 +147,31 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_config = config;
|
_config = config;
|
||||||
_licenseService = licenseService;
|
_licenseService = licenseService;
|
||||||
_downloadStore = downloadStore;
|
_downloadStore = downloadStore;
|
||||||
|
_migrationService = migrationService;
|
||||||
|
_reportDeployer = reportDeployer;
|
||||||
|
_registry = registry;
|
||||||
_http = http;
|
_http = http;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
_serverBaseUrl = config.ServerBaseUrl;
|
_serverBaseUrl = config.ServerBaseUrl;
|
||||||
_installRoot = config.InstallRoot;
|
_installRoot = config.InstallRoot;
|
||||||
_language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language;
|
_language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language;
|
||||||
|
_reportUrl = config.ReportUrl;
|
||||||
|
_documentationUrl = config.DocumentationUrl;
|
||||||
|
|
||||||
|
_dbHost = config.Database.Host;
|
||||||
|
_dbPort = config.Database.Port;
|
||||||
|
_dbUser = config.Database.User;
|
||||||
|
_dbPassword = DatabasePasswordProtector.Unprotect(config.Database.EncryptedPassword) ?? string.Empty;
|
||||||
|
_dbName = config.Database.Database;
|
||||||
|
_dbAutoApplyMigrations = config.Database.AutoApplyMigrations;
|
||||||
|
|
||||||
|
_reportHtdocsRoot = config.ReportTool.HtdocsRoot;
|
||||||
|
_reportFolderName = config.ReportTool.FolderName;
|
||||||
|
_reportAutoDeploy = config.ReportTool.AutoDeploy;
|
||||||
|
_reportMaxBackups = config.ReportTool.MaxBackups;
|
||||||
|
// Charge la liste de backups en arrière-plan (UI immédiate puis remplie quand prête)
|
||||||
|
_ = LoadReportBackupsAsync();
|
||||||
|
|
||||||
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
|
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
|
||||||
MachineId = licenseService.GetMachineId();
|
MachineId = licenseService.GetMachineId();
|
||||||
@@ -186,7 +240,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ClearCache()
|
private void ClearCache()
|
||||||
{
|
{
|
||||||
var confirm = MessageBox.Show(
|
var confirm = ThemedMessageBox.Show(
|
||||||
Strings.MsgClearCacheConfirm,
|
Strings.MsgClearCacheConfirm,
|
||||||
Strings.MsgBoxConfirm,
|
Strings.MsgBoxConfirm,
|
||||||
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
|
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
|
||||||
@@ -203,7 +257,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "ClearCache failed");
|
_logger.LogError(ex, "ClearCache failed");
|
||||||
MessageBox.Show(ex.Message, Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
ThemedMessageBox.Show(ex.Message, Strings.MsgBoxError, MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +279,24 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
_config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/');
|
_config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/');
|
||||||
_config.InstallRoot = InstallRoot;
|
_config.InstallRoot = InstallRoot;
|
||||||
_config.Language = Language ?? "auto";
|
_config.Language = Language ?? "auto";
|
||||||
|
_config.ReportUrl = ReportUrl?.Trim() ?? "";
|
||||||
|
_config.DocumentationUrl = DocumentationUrl?.Trim() ?? "";
|
||||||
|
|
||||||
|
// DB settings
|
||||||
|
_config.Database.Host = DbHost.Trim();
|
||||||
|
_config.Database.Port = DbPort > 0 ? DbPort : 3306;
|
||||||
|
_config.Database.User = DbUser.Trim();
|
||||||
|
_config.Database.EncryptedPassword = string.IsNullOrEmpty(DbPassword)
|
||||||
|
? null
|
||||||
|
: DatabasePasswordProtector.Protect(DbPassword);
|
||||||
|
_config.Database.Database = DbName.Trim();
|
||||||
|
_config.Database.AutoApplyMigrations = DbAutoApplyMigrations;
|
||||||
|
|
||||||
|
_config.ReportTool.HtdocsRoot = ReportHtdocsRoot.Trim();
|
||||||
|
_config.ReportTool.FolderName = ReportFolderName.Trim();
|
||||||
|
_config.ReportTool.AutoDeploy = ReportAutoDeploy;
|
||||||
|
_config.ReportTool.MaxBackups = Math.Max(0, ReportMaxBackups);
|
||||||
|
|
||||||
_configStore.Save(_config);
|
_configStore.Save(_config);
|
||||||
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
|
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
|
||||||
|
|
||||||
@@ -233,7 +305,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
// Le launcher doit être relancé pour que les bindings x:Static reflètent
|
// Le launcher doit être relancé pour que les bindings x:Static reflètent
|
||||||
// la nouvelle langue. Plutôt que de demander à l'utilisateur de fermer/rouvrir,
|
// la nouvelle langue. Plutôt que de demander à l'utilisateur de fermer/rouvrir,
|
||||||
// on spawne un cmd qui attend la fermeture puis relance.
|
// on spawne un cmd qui attend la fermeture puis relance.
|
||||||
var ok = MessageBox.Show(
|
var ok = ThemedMessageBox.Show(
|
||||||
Strings.MsgLanguageRestart,
|
Strings.MsgLanguageRestart,
|
||||||
Strings.MsgBoxLanguageChange,
|
Strings.MsgBoxLanguageChange,
|
||||||
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
|
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
|
||||||
@@ -264,7 +336,7 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void DeactivateLicense()
|
private void DeactivateLicense()
|
||||||
{
|
{
|
||||||
var confirm = MessageBox.Show(
|
var confirm = ThemedMessageBox.Show(
|
||||||
Strings.MsgDeactivateLicenseConfirm,
|
Strings.MsgDeactivateLicenseConfirm,
|
||||||
Strings.MsgBoxConfirm,
|
Strings.MsgBoxConfirm,
|
||||||
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||||||
@@ -289,4 +361,218 @@ public sealed partial class SettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatSize(long bytes) => Strings.FormatSize(bytes);
|
private static string FormatSize(long bytes) => Strings.FormatSize(bytes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-déploie l'outil Report depuis la dernière version PROSERVE installée
|
||||||
|
/// vers htdocs. Utile si le déploiement post-install avait échoué (XAMPP
|
||||||
|
/// déplacé, htdocs absent), ou pour resync après changement du chemin htdocs
|
||||||
|
/// dans Settings.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RedeployReportAsync()
|
||||||
|
{
|
||||||
|
var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
|
||||||
|
if (installed is null)
|
||||||
|
{
|
||||||
|
ReportDeployStatus = "Aucune version installée — installe d'abord une version qui contient _report/.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
IsDeployingReport = true;
|
||||||
|
ReportDeployStatus = $"Déploiement depuis v{installed.Version}…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Persiste les éventuels changements de chemin avant le deploy
|
||||||
|
Save();
|
||||||
|
var progress = new Progress<DeployProgress>(dp =>
|
||||||
|
{
|
||||||
|
if (dp.FilesTotal > 0)
|
||||||
|
ReportDeployStatus = $"{dp.FilesDone}/{dp.FilesTotal} : {dp.CurrentFilename}";
|
||||||
|
});
|
||||||
|
var result = await _reportDeployer.DeployAsync(installed.FolderPath, progress, CancellationToken.None);
|
||||||
|
ReportDeployStatus = result.Status switch
|
||||||
|
{
|
||||||
|
DeployStatus.Deployed => $"✓ {result.FilesDeployed} fichiers déployés vers {result.TargetPath}",
|
||||||
|
DeployStatus.SkippedNoSourceFolder => $"⚠ La version v{installed.Version} ne contient pas de _report/ — rien à déployer",
|
||||||
|
DeployStatus.XamppNotFound => $"✗ {result.ErrorMessage}",
|
||||||
|
DeployStatus.Failed => $"✗ {result.ErrorMessage}",
|
||||||
|
_ => "?",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Redeploy report failed");
|
||||||
|
ReportDeployStatus = $"✗ {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsDeployingReport = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== DB MIGRATIONS =====================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test la connexion à la DB MySQL avec les paramètres en cours de saisie.
|
||||||
|
/// Persiste temporairement les valeurs sur _config.Database avant le test
|
||||||
|
/// pour que le service les voie via son configProvider, puis remet (et le
|
||||||
|
/// Save() final écrasera quand l'utilisateur cliquera Enregistrer).
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task TestDbAsync()
|
||||||
|
{
|
||||||
|
DbConnectionStatus = Strings.SettingsTest + "…";
|
||||||
|
IsTestingDb = true;
|
||||||
|
// On applique temporairement les valeurs saisies
|
||||||
|
var oldHost = _config.Database.Host;
|
||||||
|
var oldPort = _config.Database.Port;
|
||||||
|
var oldUser = _config.Database.User;
|
||||||
|
var oldPwd = _config.Database.EncryptedPassword;
|
||||||
|
var oldDb = _config.Database.Database;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_config.Database.Host = DbHost.Trim();
|
||||||
|
_config.Database.Port = DbPort > 0 ? DbPort : 3306;
|
||||||
|
_config.Database.User = DbUser.Trim();
|
||||||
|
_config.Database.EncryptedPassword = string.IsNullOrEmpty(DbPassword)
|
||||||
|
? null
|
||||||
|
: DatabasePasswordProtector.Protect(DbPassword);
|
||||||
|
_config.Database.Database = DbName.Trim();
|
||||||
|
|
||||||
|
var err = await _migrationService.TestConnectionAsync(CancellationToken.None);
|
||||||
|
DbConnectionStatus = err is null ? "✓ OK" : $"✗ {err}";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { DbConnectionStatus = $"✗ {ex.Message}"; }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Restaure : seul Save() doit écrire pour de bon
|
||||||
|
_config.Database.Host = oldHost;
|
||||||
|
_config.Database.Port = oldPort;
|
||||||
|
_config.Database.User = oldUser;
|
||||||
|
_config.Database.EncryptedPassword = oldPwd;
|
||||||
|
_config.Database.Database = oldDb;
|
||||||
|
IsTestingDb = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-joue les migrations bundled dans le _migrations/ de la version actuellement
|
||||||
|
/// installée la plus récente. Utile si l'auto-apply post-install avait échoué (ex.
|
||||||
|
/// XAMPP éteint à ce moment-là), ou pour rejouer manuellement après ajout d'une
|
||||||
|
/// nouvelle migration côté dev.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ReplayMigrationsAsync()
|
||||||
|
{
|
||||||
|
var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
|
||||||
|
if (installed is null)
|
||||||
|
{
|
||||||
|
MigrationsReplayStatus = "Aucune version installée — installe d'abord une version avec _migrations/.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
IsReplayingMigrations = true;
|
||||||
|
MigrationsReplayStatus = $"Réapplication des migrations pour v{installed.Version}…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var progress = new Progress<MigrationProgress>(mp =>
|
||||||
|
{
|
||||||
|
if (mp.Total > 0)
|
||||||
|
MigrationsReplayStatus = $"{mp.Done + 1}/{mp.Total} : {mp.CurrentFilename}";
|
||||||
|
});
|
||||||
|
// Il faut persister les éventuelles modifs DB avant le test (le service lit la config courante)
|
||||||
|
Save();
|
||||||
|
var result = await _migrationService.ApplyMigrationsAsync(
|
||||||
|
installed.FolderPath, progress, CancellationToken.None);
|
||||||
|
if (result.AllSucceeded)
|
||||||
|
{
|
||||||
|
MigrationsReplayStatus = result.AppliedCount > 0
|
||||||
|
? $"✓ {result.AppliedCount} migration(s) appliquée(s), {result.SkippedCount} déjà en place."
|
||||||
|
: $"✓ Tout est à jour ({result.SkippedCount} déjà appliquée(s))";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MigrationsReplayStatus = $"✗ Échec : {result.FailureMessage}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Replay migrations failed");
|
||||||
|
MigrationsReplayStatus = $"✗ Erreur : {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsReplayingMigrations = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== REPORT TOOL BACKUPS =====================
|
||||||
|
|
||||||
|
private async Task LoadReportBackupsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var list = await _reportDeployer.ListBackupsAsync(CancellationToken.None);
|
||||||
|
ReportBackups.Clear();
|
||||||
|
foreach (var b in list)
|
||||||
|
ReportBackups.Add(new ReportBackupViewModel(b, RevertReportToBackupAsync));
|
||||||
|
OnPropertyChanged(nameof(HasReportBackups));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Could not list report backups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appelé par chaque ligne du tableau de backups via son own command.</summary>
|
||||||
|
private async Task RevertReportToBackupAsync(BackupInfo backup)
|
||||||
|
{
|
||||||
|
var localDate = backup.TimestampUtc.ToLocalTime();
|
||||||
|
var dateLabel = Strings.FormatLongDate(localDate);
|
||||||
|
var confirm = ThemedMessageBox.Show(
|
||||||
|
Strings.MsgRevertReportConfirm(dateLabel),
|
||||||
|
Strings.MsgBoxRevertReport,
|
||||||
|
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||||||
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
|
IsDeployingReport = true;
|
||||||
|
ReportDeployStatus = $"Revert vers {dateLabel}…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _reportDeployer.RevertAsync(backup.FolderName, CancellationToken.None);
|
||||||
|
ReportDeployStatus = result.Status == DeployStatus.Deployed
|
||||||
|
? Strings.MsgRevertReportSuccess(dateLabel)
|
||||||
|
: $"✗ {result.ErrorMessage}";
|
||||||
|
await LoadReportBackupsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Revert report failed");
|
||||||
|
ReportDeployStatus = $"✗ {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsDeployingReport = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ligne du tableau "Backups disponibles" dans Settings → Avancés → Outil Report.
|
||||||
|
/// Chaque instance porte sa propre commande Revert paramétrée pour ce backup.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class ReportBackupViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
public BackupInfo Info { get; }
|
||||||
|
public string DateDisplay => Strings.FormatLongDate(Info.TimestampUtc.ToLocalTime());
|
||||||
|
public string SizeDisplay => Strings.SettingsReportBackupSize(Strings.FormatSize(Info.SizeBytes));
|
||||||
|
public string FolderName => Info.FolderName;
|
||||||
|
|
||||||
|
private readonly Func<BackupInfo, Task> _revertHandler;
|
||||||
|
public ReportBackupViewModel(BackupInfo info, Func<BackupInfo, Task> revertHandler)
|
||||||
|
{
|
||||||
|
Info = info;
|
||||||
|
_revertHandler = revertHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private Task Revert() => _revertHandler(Info);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||||
Title="License"
|
Title="License"
|
||||||
Icon="pack://application:,,,/Resources/favicon.ico"
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
Width="500" Height="480"
|
Width="540" Height="540"
|
||||||
|
SizeToContent="Height"
|
||||||
|
MinHeight="480"
|
||||||
ResizeMode="NoResize"
|
ResizeMode="NoResize"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
Background="{StaticResource Brush.Bg.Window}">
|
Background="{StaticResource Brush.Bg.Window}">
|
||||||
@@ -16,24 +18,34 @@
|
|||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Header avec badge couleur -->
|
<!-- Header avec badge couleur. On utilise un Grid (Auto + *) pour que la
|
||||||
|
colonne texte prenne tout l'espace disponible et que le sous-titre
|
||||||
|
puisse wrapper sur plusieurs lignes (les messages "License expirée
|
||||||
|
le X. Vous pouvez encore..." font facilement 2-3 lignes). -->
|
||||||
<Border Grid.Row="0" CornerRadius="8" Padding="20,16"
|
<Border Grid.Row="0" CornerRadius="8" Padding="20,16"
|
||||||
BorderThickness="2"
|
BorderThickness="2"
|
||||||
x:Name="HeaderBorder">
|
x:Name="HeaderBorder">
|
||||||
<StackPanel Orientation="Horizontal">
|
<Grid>
|
||||||
<TextBlock x:Name="StatusIconText"
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
x:Name="StatusIconText"
|
||||||
FontSize="32" FontWeight="Bold"
|
FontSize="32" FontWeight="Bold"
|
||||||
VerticalAlignment="Center" Margin="0,0,16,0" />
|
VerticalAlignment="Top" Margin="0,0,16,0" />
|
||||||
<StackPanel VerticalAlignment="Center">
|
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||||
<TextBlock x:Name="StatusTitleText"
|
<TextBlock x:Name="StatusTitleText"
|
||||||
FontSize="18" FontWeight="SemiBold"
|
FontSize="18" FontWeight="SemiBold"
|
||||||
|
TextWrapping="Wrap"
|
||||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
<TextBlock x:Name="StatusSubtitleText"
|
<TextBlock x:Name="StatusSubtitleText"
|
||||||
FontSize="13"
|
FontSize="13"
|
||||||
|
TextWrapping="Wrap"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
Margin="0,2,0,0" />
|
Margin="0,4,0,0" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Détails -->
|
<!-- Détails -->
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public partial class LicenseDetailsDialog : Window
|
|||||||
|
|
||||||
private void OnDeactivate(object sender, RoutedEventArgs e)
|
private void OnDeactivate(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var confirm = MessageBox.Show(
|
var confirm = ThemedMessageBox.Show(
|
||||||
Strings.MsgDeactivateLicenseDetailedConfirm,
|
Strings.MsgDeactivateLicenseDetailedConfirm,
|
||||||
Strings.MsgBoxConfirm,
|
Strings.MsgBoxConfirm,
|
||||||
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
xmlns:vm="clr-namespace:PSLauncher.App.ViewModels"
|
xmlns:vm="clr-namespace:PSLauncher.App.ViewModels"
|
||||||
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||||
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
|
||||||
Title="PROSERVE Launcher"
|
Title="PROSERVE Launcher"
|
||||||
Icon="pack://application:,,,/Resources/favicon.ico"
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
Background="Black"
|
Background="Black"
|
||||||
@@ -172,19 +173,41 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</Window.Resources>
|
</Window.Resources>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Layout principal — grille 2 colonnes × 3 rangées :
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Top bar (ColSpan 2) │ Row 0
|
||||||
|
├──────────┬──────────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ Sidebar │ Content area │ Row 1
|
||||||
|
│ (Library │ (Library / Report / Doc) │
|
||||||
|
│ Report │ │
|
||||||
|
│ Doc) ├──────────────────────────────────────────────────┤
|
||||||
|
│ RowSpan2 │ Footer (DL/install) │ Row 2
|
||||||
|
│ │ visible only on Library │
|
||||||
|
└──────────┴──────────────────────────────────────────────────┘
|
||||||
|
200px *
|
||||||
|
|
||||||
|
La sidebar a RowSpan=2 → s'étend du sous-topbar jusqu'au bas
|
||||||
|
de fenêtre. Le footer est confiné à la colonne content.
|
||||||
|
-->
|
||||||
<Grid Background="Black">
|
<Grid Background="Black">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="200" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Background image : limité au body uniquement (Grid.Row="1") pour ne pas
|
<!-- Background image : limité à la zone content (Row 1, Col 1) pour ne pas
|
||||||
se faire chevaucher par le footer quand un download tourne. ImageBrush
|
passer derrière la sidebar ni le footer. ImageBrush avec AlignmentX=Right
|
||||||
avec AlignmentX=Right + AlignmentY=Bottom : si le ratio fenêtre force
|
+ AlignmentY=Bottom : si le ratio fenêtre force un crop, on rogne en
|
||||||
un crop, on rogne en haut/à gauche — le logo ASTERION VR en bas-droite
|
haut/à gauche — le logo ASTERION VR en bas-droite reste TOUJOURS visible. -->
|
||||||
reste TOUJOURS visible. -->
|
<Rectangle Grid.Row="1" Grid.Column="1" IsHitTestVisible="False">
|
||||||
<Rectangle Grid.Row="1" IsHitTestVisible="False">
|
|
||||||
<Rectangle.Fill>
|
<Rectangle.Fill>
|
||||||
<ImageBrush ImageSource="pack://application:,,,/Resources/Background.png"
|
<ImageBrush ImageSource="pack://application:,,,/Resources/Background.png"
|
||||||
Stretch="UniformToFill"
|
Stretch="UniformToFill"
|
||||||
@@ -194,8 +217,9 @@
|
|||||||
</Rectangle.Fill>
|
</Rectangle.Fill>
|
||||||
</Rectangle>
|
</Rectangle>
|
||||||
|
|
||||||
<!-- Top bar : 3 colonnes (brand / license center / chrome) -->
|
<!-- Top bar : 3 colonnes (brand / license center / chrome).
|
||||||
<Border Grid.Row="0" Background="{StaticResource Brush.Bg.Sidebar}"
|
Grid.ColumnSpan=2 → s'étend sur sidebar + content. -->
|
||||||
|
<Border Grid.Row="0" Grid.ColumnSpan="2" Background="{StaticResource Brush.Bg.Sidebar}"
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1"
|
||||||
Padding="20,12">
|
Padding="20,12">
|
||||||
<Grid>
|
<Grid>
|
||||||
@@ -315,8 +339,13 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Body : Grid pour pouvoir superposer le bouton flottant "Vérifier les MAJ" -->
|
<!-- ============== Content area (Row 1, Col 1, swap par CurrentPage) ==============
|
||||||
<Grid Grid.Row="1">
|
Superpose plusieurs blocs (Library / Report / Documentation) gérés par
|
||||||
|
leur Visibility liée à CurrentPage. -->
|
||||||
|
<Grid Grid.Row="1" Grid.Column="1">
|
||||||
|
|
||||||
|
<!-- ====================== LIBRARY PAGE ====================== -->
|
||||||
|
<Grid Visibility="{Binding IsLibrary, Converter={StaticResource BoolToVisibility}}">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Margin="32,28">
|
<StackPanel Margin="32,28">
|
||||||
|
|
||||||
@@ -547,11 +576,75 @@
|
|||||||
FontSize="11"
|
FontSize="11"
|
||||||
Foreground="White" />
|
Foreground="White" />
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<!-- ====================== END LIBRARY PAGE ====================== -->
|
||||||
|
|
||||||
<!-- Footer : visible uniquement quand busy / status à afficher.
|
<!-- ====================== REPORT PAGE (WebView2) ====================== -->
|
||||||
Le bouton "Vérifier les MAJ" a été déplacé en flottant au-dessus du body. -->
|
<Grid Background="{StaticResource Brush.Bg.Window}"
|
||||||
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
|
Visibility="{Binding IsReport, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<wv2:WebView2 x:Name="ReportWebView"
|
||||||
|
Source="{Binding ReportUri, Mode=OneWay}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ====================== DOCUMENTATION PAGE ====================== -->
|
||||||
|
<Grid Background="{StaticResource Brush.Bg.Window}"
|
||||||
|
Visibility="{Binding IsDocumentation, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<!-- WebView2 si une URL est configurée, sinon un placeholder explicatif -->
|
||||||
|
<wv2:WebView2 x:Name="DocsWebView"
|
||||||
|
Source="{Binding DocumentationUri, Mode=OneWay}"
|
||||||
|
Visibility="{Binding HasDocumentationUrl, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.DocsPlaceholder}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="14" TextAlignment="Center" TextWrapping="Wrap"
|
||||||
|
MaxWidth="500"
|
||||||
|
Visibility="{Binding HasDocumentationUrl, Converter={StaticResource InverseBoolToVisibility}}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
<!-- ============== END Content area ============== -->
|
||||||
|
|
||||||
|
<!-- ============== Left sidebar : nav buttons ==============
|
||||||
|
Row 1 + Row 2 (RowSpan=2) → la sidebar s'étend depuis le bas du
|
||||||
|
top bar jusqu'en bas de la fenêtre, peu importe la visibilité du
|
||||||
|
footer ou de la taille de la zone content. -->
|
||||||
|
<Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="0"
|
||||||
|
Background="#0A0A0E"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="0,0,1,0">
|
||||||
|
<StackPanel Orientation="Vertical" Margin="0,16,0,0">
|
||||||
|
<!-- Style des boutons définis dans Theme.xaml (NavButton) -->
|
||||||
|
<Button Style="{StaticResource NavButton}"
|
||||||
|
Command="{Binding NavigateLibraryCommand}"
|
||||||
|
Tag="{Binding IsLibrary}">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="📚" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.NavLibrary}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Style="{StaticResource NavButton}"
|
||||||
|
Command="{Binding NavigateReportCommand}"
|
||||||
|
Tag="{Binding IsReport}">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="📊" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.NavReport}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Style="{StaticResource NavButton}"
|
||||||
|
Command="{Binding NavigateDocumentationCommand}"
|
||||||
|
Tag="{Binding IsDocumentation}">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="📖" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.NavDocumentation}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Footer : Row 2, Col 1 → cantonné à la colonne content, sous la zone
|
||||||
|
Library/Report/Doc. La sidebar (RowSpan=2) le contourne par sa colonne. -->
|
||||||
|
<Border Grid.Row="2" Grid.Column="1"
|
||||||
|
Background="{StaticResource Brush.Bg.Footer}"
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
|
||||||
Padding="20,10"
|
Padding="20,10"
|
||||||
Visibility="{Binding FooterVisibility}">
|
Visibility="{Binding FooterVisibility}">
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public partial class MainWindow : Window
|
|||||||
if (DataContext is MainViewModel vm && vm.HasActiveDownload)
|
if (DataContext is MainViewModel vm && vm.HasActiveDownload)
|
||||||
{
|
{
|
||||||
var version = vm.ActiveDownloadVersion ?? "?";
|
var version = vm.ActiveDownloadVersion ?? "?";
|
||||||
var result = MessageBox.Show(
|
var result = ThemedMessageBox.Show(
|
||||||
Strings.MsgQuitWhileDownloadingConfirm(version),
|
Strings.MsgQuitWhileDownloadingConfirm(version),
|
||||||
Strings.MsgBoxQuit,
|
Strings.MsgBoxQuit,
|
||||||
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||||||
|
|||||||
@@ -102,6 +102,24 @@ internal static class MarkdownTheming
|
|||||||
foreach (var i in h.Inlines.ToList())
|
foreach (var i in h.Inlines.ToList())
|
||||||
ApplyToInline(i, accent, fgSecondary, accent);
|
ApplyToInline(i, accent, fgSecondary, accent);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case Run r:
|
||||||
|
// Markdig.Wpf rend `inline code` en tant que <Run> avec un Style
|
||||||
|
// par défaut qui pose un Background clair + Foreground "fancy" — illisible
|
||||||
|
// sur notre thème sombre. On détecte le code inline par sa FontFamily
|
||||||
|
// (monospace appliquée par le style Markdig) et on rebadge avec des couleurs
|
||||||
|
// qui matchent le reste de la UI : fond noir foncé + texte primaire.
|
||||||
|
var fontFamilyName = r.FontFamily?.Source ?? string.Empty;
|
||||||
|
if (fontFamilyName.Contains("Consolas", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| fontFamilyName.Contains("Courier", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| fontFamilyName.Contains("Typewriter", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
r.Background = new SolidColorBrush(Color.FromRgb(0x1A, 0x1F, 0x2A));
|
||||||
|
r.Foreground = new SolidColorBrush(Color.FromRgb(0xCD, 0xD6, 0xE4));
|
||||||
|
r.FontFamily = new FontFamily("Cascadia Code, Consolas, monospace");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case Span s:
|
case Span s:
|
||||||
foreach (var i in s.Inlines.ToList())
|
foreach (var i in s.Inlines.ToList())
|
||||||
ApplyToInline(i, fg, fgSecondary, accent);
|
ApplyToInline(i, fg, fgSecondary, accent);
|
||||||
|
|||||||
@@ -142,6 +142,45 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Section "À propos" : version + copyright. Visible (pas dans l'Expander),
|
||||||
|
placée entre Langue et les paramètres avancés repliés. C'est l'info que
|
||||||
|
le support demande en premier en cas de bug.
|
||||||
|
-->
|
||||||
|
<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.SettingsAbout}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,8" />
|
||||||
|
<TextBlock FontSize="13"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}">
|
||||||
|
<Run Text="{x:Static loc:Strings.SettingsLauncherVersion}" />
|
||||||
|
<Run Text=" " />
|
||||||
|
<Run Text="{Binding LauncherVersion, Mode=OneWay}" FontWeight="SemiBold" />
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.Copyright}"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,4,0,0" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Sections "techniques" : Server / Installation / Cache / Database / Logs
|
||||||
|
repliées par défaut sous un Expander pour ne pas noyer les sections
|
||||||
|
importantes (License, Langue, About) qui restent dépliées.
|
||||||
|
Placé en BAS de la fenêtre car ce sont des trucs de power-user / support.
|
||||||
|
-->
|
||||||
|
<Expander Header="{x:Static loc:Strings.SettingsAdvanced}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Margin="0,0,0,16" IsExpanded="False">
|
||||||
|
<StackPanel Margin="0,12,0,0">
|
||||||
|
|
||||||
<!-- Serveur -->
|
<!-- Serveur -->
|
||||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
@@ -174,6 +213,23 @@
|
|||||||
<TextBlock Text="{Binding ConnectionStatus}"
|
<TextBlock Text="{Binding ConnectionStatus}"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" />
|
FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<!-- URLs des onglets latéraux Report / Documentation -->
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsReportUrl}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,16,0,4" />
|
||||||
|
<TextBox Text="{Binding ReportUrl, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsDocsUrl}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,12,0,4" />
|
||||||
|
<TextBox Text="{Binding DocumentationUrl, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -248,7 +304,228 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Logs / About -->
|
<!-- Base de données (XAMPP / MySQL) — config + test + replay -->
|
||||||
|
<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.SettingsDatabase}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<Grid Margin="0,0,0,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="100" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Margin="0,0,8,0">
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsDbHost}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,0,0,4" />
|
||||||
|
<TextBox Text="{Binding DbHost, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1">
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsDbPort}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,0,0,4" />
|
||||||
|
<TextBox Text="{Binding DbPort, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Margin="0,0,0,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" Margin="0,0,8,0">
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsDbUser}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,0,0,4" />
|
||||||
|
<TextBox Text="{Binding DbUser, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1">
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsDbPassword}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,0,0,4" />
|
||||||
|
<TextBox Text="{Binding DbPassword, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsDbName}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,0,0,4" />
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox Grid.Column="0"
|
||||||
|
Text="{Binding DbName, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
<Button Grid.Column="1" Margin="8,0,0,0"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsTest}"
|
||||||
|
Command="{Binding TestDbCommand}"
|
||||||
|
IsEnabled="{Binding IsTestingDb, Converter={StaticResource InverseBoolToBool}}" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{Binding DbConnectionStatus}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<CheckBox IsChecked="{Binding DbAutoApplyMigrations}"
|
||||||
|
Margin="0,12,0,0"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsDbAutoMigrate}" />
|
||||||
|
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding MigrationsReplayStatus}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" VerticalAlignment="Center"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsDbReplay}"
|
||||||
|
Command="{Binding ReplayMigrationsCommand}"
|
||||||
|
IsEnabled="{Binding IsReplayingMigrations, Converter={StaticResource InverseBoolToBool}}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsDbHint}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontStyle="Italic" FontSize="11" Margin="0,8,0,0"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Outil Report (XAMPP htdocs deploy) -->
|
||||||
|
<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.SettingsReportTool}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,8" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsReportHtdocs}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,0,0,4" />
|
||||||
|
<TextBox Text="{Binding ReportHtdocsRoot, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsReportFolder}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,12,0,4" />
|
||||||
|
<TextBox Text="{Binding ReportFolderName, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
|
||||||
|
<CheckBox IsChecked="{Binding ReportAutoDeploy}"
|
||||||
|
Margin="0,12,0,0"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsReportAutoDeploy}" />
|
||||||
|
|
||||||
|
<!-- Conserver N backups -->
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="80" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{x:Static loc:Strings.SettingsReportMaxBackups}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Column="1"
|
||||||
|
Text="{Binding ReportMaxBackups, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid Margin="0,12,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
Text="{Binding ReportDeployStatus}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" VerticalAlignment="Center"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsReportRedeploy}"
|
||||||
|
Command="{Binding RedeployReportCommand}"
|
||||||
|
IsEnabled="{Binding IsDeployingReport, Converter={StaticResource InverseBoolToBool}}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Liste des backups disponibles avec bouton Revert par ligne -->
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsReportBackups}"
|
||||||
|
FontWeight="Bold" FontSize="11"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,16,0,6" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.SettingsReportNoBackups}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" FontStyle="Italic"
|
||||||
|
Visibility="{Binding HasReportBackups, Converter={StaticResource InverseBoolToVisibility}}" />
|
||||||
|
<ItemsControl ItemsSource="{Binding ReportBackups}"
|
||||||
|
Visibility="{Binding HasReportBackups, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="#1A1A20"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Grid.Column="0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding DateDisplay}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
FontSize="13" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="{Binding SizeDisplay}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
Text="{Binding FolderName}"
|
||||||
|
FontFamily="Consolas" FontSize="10"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsReportRevert}"
|
||||||
|
Command="{Binding RevertCommand}"
|
||||||
|
Padding="12,6" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Logs (path + open button) -->
|
||||||
<Border Background="{StaticResource Brush.Bg.Card}"
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
CornerRadius="6" Padding="20" Margin="0,0,0,16">
|
CornerRadius="6" Padding="20" Margin="0,0,0,16">
|
||||||
@@ -257,17 +534,7 @@
|
|||||||
FontSize="11" FontWeight="Bold"
|
FontSize="11" FontWeight="Bold"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
Margin="0,0,0,8" />
|
Margin="0,0,0,8" />
|
||||||
<TextBlock FontSize="13"
|
<Grid>
|
||||||
Foreground="{StaticResource Brush.Text.Primary}">
|
|
||||||
<Run Text="{x:Static loc:Strings.SettingsLauncherVersion}" />
|
|
||||||
<Run Text=" " />
|
|
||||||
<Run Text="{Binding LauncherVersion, Mode=OneWay}" FontWeight="SemiBold" />
|
|
||||||
</TextBlock>
|
|
||||||
<TextBlock Text="{x:Static loc:Strings.Copyright}"
|
|
||||||
FontSize="12"
|
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
|
||||||
Margin="0,4,0,0" />
|
|
||||||
<Grid Margin="0,8,0,0">
|
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
@@ -290,6 +557,9 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Expander>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
|||||||
72
src/PSLauncher.App/Views/ThemedMessageBox.xaml
Normal file
72
src/PSLauncher.App/Views/ThemedMessageBox.xaml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<Window x:Class="PSLauncher.App.Views.ThemedMessageBox"
|
||||||
|
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=""
|
||||||
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
|
SizeToContent="Height"
|
||||||
|
Width="480"
|
||||||
|
MinHeight="180"
|
||||||
|
MaxHeight="600"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{StaticResource Brush.Bg.Window}">
|
||||||
|
<Border BorderBrush="{StaticResource Brush.Border}" BorderThickness="1">
|
||||||
|
<Grid Margin="24,20">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<TextBlock Grid.Row="0"
|
||||||
|
x:Name="TitleText"
|
||||||
|
FontSize="16" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Margin="0,0,0,12" />
|
||||||
|
|
||||||
|
<!-- Icon + Message -->
|
||||||
|
<Grid Grid.Row="1">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Grid.Column="0"
|
||||||
|
x:Name="IconText"
|
||||||
|
FontSize="36"
|
||||||
|
Margin="0,2,16,0"
|
||||||
|
VerticalAlignment="Top" />
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
x:Name="MessageText"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
FontSize="13" LineHeight="20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<StackPanel Grid.Row="2"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Margin="0,24,0,0">
|
||||||
|
<Button x:Name="Btn1"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
MinWidth="80" Padding="16,8" Margin="0,0,8,0"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Click="OnBtn1Click" />
|
||||||
|
<Button x:Name="Btn2"
|
||||||
|
Style="{StaticResource SecondaryButton}"
|
||||||
|
MinWidth="80" Padding="16,8" Margin="0,0,8,0"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Click="OnBtn2Click" />
|
||||||
|
<Button x:Name="Btn3"
|
||||||
|
Style="{StaticResource AccentButton}"
|
||||||
|
MinWidth="80" Padding="16,8"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Click="OnBtn3Click" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
187
src/PSLauncher.App/Views/ThemedMessageBox.xaml.cs
Normal file
187
src/PSLauncher.App/Views/ThemedMessageBox.xaml.cs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
|
|
||||||
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drop-in pour <see cref="System.Windows.MessageBox"/> avec un look qui colle
|
||||||
|
/// au reste de la UI (dark theme, accent ASTERION). API copiée :
|
||||||
|
/// <code>
|
||||||
|
/// ThemedMessageBox.Show("Texte", "Titre", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||||||
|
/// </code>
|
||||||
|
///
|
||||||
|
/// Notes :
|
||||||
|
/// - <see cref="MessageBoxImage"/> est mappé vers une icône emoji + couleur.
|
||||||
|
/// - Les libellés des boutons (OK / Yes / No / Cancel) sont localisés via <see cref="Strings"/>.
|
||||||
|
/// - Owner = MainWindow par défaut (centrage), peut être passé explicitement.
|
||||||
|
/// - Esc renvoie Cancel ou No selon le bouton défini comme cancel.
|
||||||
|
/// </summary>
|
||||||
|
public partial class ThemedMessageBox : Window
|
||||||
|
{
|
||||||
|
public MessageBoxResult Result { get; private set; } = MessageBoxResult.None;
|
||||||
|
|
||||||
|
private MessageBoxResult _btn1Result = MessageBoxResult.None;
|
||||||
|
private MessageBoxResult _btn2Result = MessageBoxResult.None;
|
||||||
|
private MessageBoxResult _btn3Result = MessageBoxResult.None;
|
||||||
|
|
||||||
|
private ThemedMessageBox(string message, string title, MessageBoxButton button,
|
||||||
|
MessageBoxImage icon, MessageBoxResult defaultResult)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
Title = title;
|
||||||
|
TitleText.Text = title;
|
||||||
|
MessageText.Text = message;
|
||||||
|
SetIcon(icon);
|
||||||
|
SetupButtons(button, defaultResult);
|
||||||
|
|
||||||
|
// Esc = cancel/no
|
||||||
|
PreviewKeyDown += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Escape)
|
||||||
|
{
|
||||||
|
Result = button switch
|
||||||
|
{
|
||||||
|
MessageBoxButton.OK => MessageBoxResult.OK,
|
||||||
|
MessageBoxButton.OKCancel => MessageBoxResult.Cancel,
|
||||||
|
MessageBoxButton.YesNo => MessageBoxResult.No,
|
||||||
|
MessageBoxButton.YesNoCancel => MessageBoxResult.Cancel,
|
||||||
|
_ => MessageBoxResult.None,
|
||||||
|
};
|
||||||
|
DialogResult = false;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetIcon(MessageBoxImage icon)
|
||||||
|
{
|
||||||
|
// NOTE : MessageBoxImage.Stop ≡ Error (16) et Asterisk ≡ Information (64)
|
||||||
|
// côté enum .NET, donc on ne switche que sur les noms canoniques.
|
||||||
|
switch (icon)
|
||||||
|
{
|
||||||
|
case MessageBoxImage.Error: // = Stop = Hand
|
||||||
|
IconText.Text = "⛔";
|
||||||
|
IconText.Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44));
|
||||||
|
break;
|
||||||
|
case MessageBoxImage.Warning: // = Exclamation
|
||||||
|
IconText.Text = "⚠";
|
||||||
|
IconText.Foreground = new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B));
|
||||||
|
break;
|
||||||
|
case MessageBoxImage.Question:
|
||||||
|
IconText.Text = "❓";
|
||||||
|
break;
|
||||||
|
case MessageBoxImage.Information: // = Asterisk
|
||||||
|
IconText.Text = "ℹ";
|
||||||
|
IconText.Foreground = new SolidColorBrush(Color.FromRgb(0x3B, 0x82, 0xF6));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
IconText.Visibility = Visibility.Collapsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configure jusqu'à 3 boutons. L'ordre visuel est gauche → droite, avec
|
||||||
|
/// le bouton "principal" (default) en accent à l'extrême droite. Les autres
|
||||||
|
/// en secondary à gauche.
|
||||||
|
/// </summary>
|
||||||
|
private void SetupButtons(MessageBoxButton button, MessageBoxResult defaultResult)
|
||||||
|
{
|
||||||
|
// Liste (label, result) dans l'ordre où on veut les afficher
|
||||||
|
var btns = button switch
|
||||||
|
{
|
||||||
|
MessageBoxButton.OK => new[] { (Strings.ActionOk, MessageBoxResult.OK) },
|
||||||
|
MessageBoxButton.OKCancel => new[] {
|
||||||
|
(Strings.ActionCancel, MessageBoxResult.Cancel),
|
||||||
|
(Strings.ActionOk, MessageBoxResult.OK),
|
||||||
|
},
|
||||||
|
MessageBoxButton.YesNo => new[] {
|
||||||
|
(Strings.ActionNo, MessageBoxResult.No),
|
||||||
|
(Strings.ActionYes, MessageBoxResult.Yes),
|
||||||
|
},
|
||||||
|
MessageBoxButton.YesNoCancel => new[] {
|
||||||
|
(Strings.ActionCancel, MessageBoxResult.Cancel),
|
||||||
|
(Strings.ActionNo, MessageBoxResult.No),
|
||||||
|
(Strings.ActionYes, MessageBoxResult.Yes),
|
||||||
|
},
|
||||||
|
_ => new[] { (Strings.ActionOk, MessageBoxResult.OK) },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Btn3 = bouton accent (le plus à droite, principal). Toujours rempli.
|
||||||
|
// Btn2 = secondary à sa gauche, optionnel.
|
||||||
|
// Btn1 = secondary tout à gauche, optionnel.
|
||||||
|
// On remplit en partant de la fin pour que l'accent soit toujours sur Btn3.
|
||||||
|
var slots = new[] { Btn1, Btn2, Btn3 };
|
||||||
|
var resultSetters = new System.Action<MessageBoxResult>[]
|
||||||
|
{
|
||||||
|
r => _btn1Result = r,
|
||||||
|
r => _btn2Result = r,
|
||||||
|
r => _btn3Result = r,
|
||||||
|
};
|
||||||
|
|
||||||
|
int startSlot = 3 - btns.Length;
|
||||||
|
for (int i = 0; i < btns.Length; i++)
|
||||||
|
{
|
||||||
|
int slotIdx = startSlot + i;
|
||||||
|
slots[slotIdx].Content = btns[i].Item1;
|
||||||
|
slots[slotIdx].Visibility = Visibility.Visible;
|
||||||
|
resultSetters[slotIdx](btns[i].Item2);
|
||||||
|
// Default = celui dont le result match defaultResult
|
||||||
|
if (btns[i].Item2 == defaultResult)
|
||||||
|
slots[slotIdx].IsDefault = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si aucun match exact pour le default, on prend le bouton principal (Btn3)
|
||||||
|
if (Btn3.Visibility == Visibility.Visible && !Btn1.IsDefault && !Btn2.IsDefault && !Btn3.IsDefault)
|
||||||
|
Btn3.IsDefault = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBtn1Click(object sender, RoutedEventArgs e) => Finish(_btn1Result);
|
||||||
|
private void OnBtn2Click(object sender, RoutedEventArgs e) => Finish(_btn2Result);
|
||||||
|
private void OnBtn3Click(object sender, RoutedEventArgs e) => Finish(_btn3Result);
|
||||||
|
|
||||||
|
private void Finish(MessageBoxResult result)
|
||||||
|
{
|
||||||
|
Result = result;
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== STATIC SHOW OVERLOADS =====================
|
||||||
|
|
||||||
|
public static MessageBoxResult Show(string message)
|
||||||
|
=> Show(message, string.Empty, MessageBoxButton.OK, MessageBoxImage.None, MessageBoxResult.OK);
|
||||||
|
|
||||||
|
public static MessageBoxResult Show(string message, string title)
|
||||||
|
=> Show(message, title, MessageBoxButton.OK, MessageBoxImage.None, MessageBoxResult.OK);
|
||||||
|
|
||||||
|
public static MessageBoxResult Show(string message, string title, MessageBoxButton button)
|
||||||
|
=> Show(message, title, button, MessageBoxImage.None, DefaultFor(button));
|
||||||
|
|
||||||
|
public static MessageBoxResult Show(string message, string title, MessageBoxButton button, MessageBoxImage icon)
|
||||||
|
=> Show(message, title, button, icon, DefaultFor(button));
|
||||||
|
|
||||||
|
public static MessageBoxResult Show(string message, string title, MessageBoxButton button,
|
||||||
|
MessageBoxImage icon, MessageBoxResult defaultResult)
|
||||||
|
{
|
||||||
|
var owner = Application.Current?.Windows.OfType<Window>().FirstOrDefault(w => w.IsActive)
|
||||||
|
?? Application.Current?.MainWindow;
|
||||||
|
var dlg = new ThemedMessageBox(message, title, button, icon, defaultResult);
|
||||||
|
if (owner is not null && owner.IsVisible) dlg.Owner = owner;
|
||||||
|
else dlg.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||||
|
dlg.ShowDialog();
|
||||||
|
return dlg.Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MessageBoxResult DefaultFor(MessageBoxButton button) => button switch
|
||||||
|
{
|
||||||
|
MessageBoxButton.OK => MessageBoxResult.OK,
|
||||||
|
MessageBoxButton.OKCancel => MessageBoxResult.OK,
|
||||||
|
MessageBoxButton.YesNo => MessageBoxResult.Yes,
|
||||||
|
MessageBoxButton.YesNoCancel => MessageBoxResult.Yes,
|
||||||
|
_ => MessageBoxResult.None,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -102,6 +102,9 @@ public static class Strings
|
|||||||
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
|
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
|
||||||
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
|
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
|
||||||
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء");
|
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء");
|
||||||
|
public static string ActionOk => T("OK", "OK", "确定", "ตกลง", "موافق");
|
||||||
|
public static string ActionYes => T("Oui", "Yes", "是", "ใช่", "نعم");
|
||||||
|
public static string ActionNo => T("Non", "No", "否", "ไม่", "لا");
|
||||||
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً");
|
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً");
|
||||||
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ");
|
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ");
|
||||||
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق");
|
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق");
|
||||||
@@ -138,6 +141,57 @@ public static class Strings
|
|||||||
public static string SettingsInstallation => T("INSTALLATION", "INSTALLATION", "安装", "การติดตั้ง", "التثبيت");
|
public static string SettingsInstallation => T("INSTALLATION", "INSTALLATION", "安装", "การติดตั้ง", "التثبيت");
|
||||||
public static string SettingsCache => T("CACHE DE TÉLÉCHARGEMENTS", "DOWNLOAD CACHE", "下载缓存", "แคชดาวน์โหลด", "ذاكرة التخزين المؤقت للتنزيلات");
|
public static string SettingsCache => T("CACHE DE TÉLÉCHARGEMENTS", "DOWNLOAD CACHE", "下载缓存", "แคชดาวน์โหลด", "ذاكرة التخزين المؤقت للتنزيلات");
|
||||||
public static string SettingsLogs => T("LOGS & APPLICATION", "LOGS & APP", "日志与应用", "บันทึกและแอป", "السجلات والتطبيق");
|
public static string SettingsLogs => T("LOGS & APPLICATION", "LOGS & APP", "日志与应用", "บันทึกและแอป", "السجلات والتطبيق");
|
||||||
|
public static string SettingsDatabase => T("BASE DE DONNÉES (XAMPP / MySQL)", "DATABASE (XAMPP / MySQL)", "数据库(XAMPP / MySQL)", "ฐานข้อมูล (XAMPP / MySQL)", "قاعدة البيانات (XAMPP / MySQL)");
|
||||||
|
public static string SettingsDbHost => T("Hôte", "Host", "主机", "โฮสต์", "المضيف");
|
||||||
|
public static string SettingsDbPort => T("Port", "Port", "端口", "พอร์ต", "المنفذ");
|
||||||
|
public static string SettingsDbUser => T("Utilisateur", "User", "用户", "ผู้ใช้", "المستخدم");
|
||||||
|
public static string SettingsDbPassword => T("Mot de passe", "Password", "密码", "รหัสผ่าน", "كلمة المرور");
|
||||||
|
public static string SettingsDbName => T("Nom de la base", "Database name", "数据库名称", "ชื่อฐานข้อมูล", "اسم قاعدة البيانات");
|
||||||
|
public static string SettingsDbAutoMigrate => T(
|
||||||
|
"Appliquer automatiquement les migrations à l'install d'une nouvelle version",
|
||||||
|
"Automatically apply migrations when installing a new version",
|
||||||
|
"安装新版本时自动应用迁移",
|
||||||
|
"ปรับใช้การโยกย้ายโดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่",
|
||||||
|
"تطبيق الترقيات تلقائياً عند تثبيت نسخة جديدة"
|
||||||
|
);
|
||||||
|
public static string SettingsDbReplay => T("🔁 Rejouer les migrations", "🔁 Replay migrations", "🔁 重新执行迁移", "🔁 เรียกใช้การโยกย้ายอีกครั้ง", "🔁 إعادة تشغيل الترقيات");
|
||||||
|
// ==================== RIGHT NAV SIDEBAR ====================
|
||||||
|
public static string NavLibrary => T("Bibliothèque", "Library", "库", "ห้องสมุด", "المكتبة");
|
||||||
|
public static string NavReport => T("Statistiques", "Reports", "统计", "รายงาน", "التقارير");
|
||||||
|
public static string NavDocumentation => T("Documentation", "Documentation","文档", "เอกสาร", "التوثيق");
|
||||||
|
|
||||||
|
public static string SettingsReportUrl => T("URL de l'outil de reporting local", "Local reporting tool URL", "本地报告工具 URL", "URL ของเครื่องมือรายงานในเครื่อง", "عنوان أداة التقارير المحلية");
|
||||||
|
public static string SettingsDocsUrl => T("URL de la documentation", "Documentation URL", "文档 URL", "URL ของเอกสาร", "عنوان التوثيق");
|
||||||
|
public static string DocsPlaceholder => T(
|
||||||
|
"Aucune URL de documentation configurée.\n\nVa dans Paramètres → Avancés → Base de données pour pointer vers la documentation locale ou en ligne.",
|
||||||
|
"No documentation URL configured.\n\nGo to Settings → Advanced → Database to point to local or online documentation.",
|
||||||
|
"未配置文档 URL。\n\n前往 设置 → 高级 → 数据库 以指向本地或在线文档。",
|
||||||
|
"ยังไม่ได้กำหนด URL เอกสาร\n\nไปที่ การตั้งค่า → ขั้นสูง → ฐานข้อมูล เพื่อชี้ไปยังเอกสารในเครื่องหรือออนไลน์",
|
||||||
|
"لم يتم تكوين عنوان التوثيق.\n\nاذهب إلى الإعدادات → متقدم → قاعدة البيانات للإشارة إلى التوثيق المحلي أو عبر الإنترنت."
|
||||||
|
);
|
||||||
|
public static string WebViewLoadError(string url, string detail) => T(
|
||||||
|
$"Impossible de charger {url}\n\n{detail}\n\nVérifie que XAMPP est démarré et que l'URL est correcte (Paramètres → Avancés).",
|
||||||
|
$"Could not load {url}\n\n{detail}\n\nCheck that XAMPP is running and the URL is correct (Settings → Advanced).",
|
||||||
|
$"无法加载 {url}\n\n{detail}\n\n请检查 XAMPP 是否正在运行以及 URL 是否正确(设置 → 高级)。",
|
||||||
|
$"ไม่สามารถโหลด {url}\n\n{detail}\n\nตรวจสอบว่า XAMPP กำลังทำงานและ URL ถูกต้อง (ตั้งค่า → ขั้นสูง)",
|
||||||
|
$"تعذر تحميل {url}\n\n{detail}\n\nتأكد من تشغيل XAMPP وأن العنوان صحيح (الإعدادات → متقدم)."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static string SettingsAdvanced => T(
|
||||||
|
"▸ PARAMÈTRES AVANCÉS (serveur, installation, base de données, logs)",
|
||||||
|
"▸ ADVANCED SETTINGS (server, installation, database, logs)",
|
||||||
|
"▸ 高级设置(服务器、安装、数据库、日志)",
|
||||||
|
"▸ ตั้งค่าขั้นสูง (เซิร์ฟเวอร์ การติดตั้ง ฐานข้อมูล บันทึก)",
|
||||||
|
"▸ الإعدادات المتقدمة (الخادم، التثبيت، قاعدة البيانات، السجلات)"
|
||||||
|
);
|
||||||
|
public static string SettingsAbout => T("À PROPOS", "ABOUT", "关于", "เกี่ยวกับ", "حول");
|
||||||
|
public static string SettingsDbHint => T(
|
||||||
|
"Le launcher applique automatiquement les scripts SQL bundled dans _migrations/ de chaque ZIP PROSERVE. Mot de passe stocké chiffré DPAPI.",
|
||||||
|
"The launcher automatically applies SQL scripts bundled in each PROSERVE ZIP's _migrations/ folder. Password stored DPAPI-encrypted.",
|
||||||
|
"启动器自动应用每个 PROSERVE ZIP 的 _migrations/ 文件夹中捆绑的 SQL 脚本。密码使用 DPAPI 加密存储。",
|
||||||
|
"Launcher จะปรับใช้สคริปต์ SQL ที่บรรจุใน _migrations/ ของ ZIP PROSERVE แต่ละชุดโดยอัตโนมัติ รหัสผ่านถูกเก็บแบบเข้ารหัส DPAPI",
|
||||||
|
"يطبق المُشغِّل تلقائياً نصوص SQL المرفقة في _migrations/ لكل ZIP من PROSERVE. كلمة المرور مُخزَّنة بتشفير DPAPI."
|
||||||
|
);
|
||||||
public static string SettingsLanguage => T("LANGUE", "LANGUAGE", "语言", "ภาษา", "اللغة");
|
public static string SettingsLanguage => T("LANGUE", "LANGUAGE", "语言", "ภาษา", "اللغة");
|
||||||
public static string SettingsLanguageHint => T(
|
public static string SettingsLanguageHint => T(
|
||||||
"Le launcher redémarrera pour appliquer le changement.",
|
"Le launcher redémarrera pour appliquer le changement.",
|
||||||
@@ -401,6 +455,103 @@ public static class Strings
|
|||||||
$"🔍 التحقق من SHA-256 v{version}: {percent}%"
|
$"🔍 التحقق من SHA-256 v{version}: {percent}%"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ==================== DB MIGRATIONS ====================
|
||||||
|
public static string StatusApplyingMigrations(string version) => T(
|
||||||
|
$"Application des migrations base de données v{version}…",
|
||||||
|
$"Applying database migrations for v{version}…",
|
||||||
|
$"正在应用 v{version} 的数据库迁移…",
|
||||||
|
$"กำลังปรับใช้การโยกย้ายฐานข้อมูล v{version}…",
|
||||||
|
$"جارٍ تطبيق ترقيات قاعدة البيانات v{version}…"
|
||||||
|
);
|
||||||
|
public static string ProgressMigration(int done, int total, string filename) => T(
|
||||||
|
$"🗄 Migration {done}/{total} : {filename}",
|
||||||
|
$"🗄 Migration {done}/{total}: {filename}",
|
||||||
|
$"🗄 迁移 {done}/{total}:{filename}",
|
||||||
|
$"🗄 การโยกย้าย {done}/{total}: {filename}",
|
||||||
|
$"🗄 الترقية {done}/{total}: {filename}"
|
||||||
|
);
|
||||||
|
public static string MsgBoxMigrationFailed => T("Migration DB échouée", "DB migration failed", "数据库迁移失败", "การโยกย้าย DB ล้มเหลว", "فشل ترقية قاعدة البيانات");
|
||||||
|
|
||||||
|
// ==================== REPORT TOOL DEPLOY ====================
|
||||||
|
public static string StatusDeployingReport(string version) => T(
|
||||||
|
$"Déploiement de l'outil Report v{version}…",
|
||||||
|
$"Deploying Report tool for v{version}…",
|
||||||
|
$"正在部署 v{version} 的报告工具…",
|
||||||
|
$"กำลังปรับใช้เครื่องมือรายงาน v{version}…",
|
||||||
|
$"جارٍ نشر أداة التقارير v{version}…"
|
||||||
|
);
|
||||||
|
public static string ProgressReportDeploy(int done, int total, string filename) => T(
|
||||||
|
$"📂 Déploiement Report : {done}/{total} — {filename}",
|
||||||
|
$"📂 Deploying Report: {done}/{total} — {filename}",
|
||||||
|
$"📂 部署报告:{done}/{total} — {filename}",
|
||||||
|
$"📂 ปรับใช้รายงาน: {done}/{total} — {filename}",
|
||||||
|
$"📂 نشر التقرير: {done}/{total} — {filename}"
|
||||||
|
);
|
||||||
|
public static string MsgBoxReportDeployFailed => T("Déploiement Report échoué", "Report deploy failed", "报告部署失败", "การปรับใช้รายงานล้มเหลว", "فشل نشر التقرير");
|
||||||
|
public static string MsgReportXamppNotFound(string path) => T(
|
||||||
|
$"L'outil Report n'a pas pu être déployé : le dossier XAMPP {path} est introuvable.\n\nVérifie que XAMPP est installé à cet emplacement (par défaut C:\\xampp) ou ajuste le chemin dans Paramètres → Avancés → Outil Report.",
|
||||||
|
$"The Report tool could not be deployed: XAMPP folder {path} not found.\n\nMake sure XAMPP is installed there (default C:\\xampp) or adjust the path in Settings → Advanced → Report Tool.",
|
||||||
|
$"无法部署报告工具:找不到 XAMPP 文件夹 {path}。\n\n请确保 XAMPP 安装在该位置(默认 C:\\xampp),或在 设置 → 高级 → 报告工具 中调整路径。",
|
||||||
|
$"ไม่สามารถปรับใช้เครื่องมือรายงานได้: ไม่พบโฟลเดอร์ XAMPP {path}\n\nตรวจสอบว่า XAMPP ติดตั้งที่ตำแหน่งนั้น (ค่าเริ่มต้น C:\\xampp) หรือปรับเส้นทางใน ตั้งค่า → ขั้นสูง → เครื่องมือรายงาน",
|
||||||
|
$"تعذر نشر أداة التقارير: مجلد XAMPP {path} غير موجود.\n\nتأكد من تثبيت XAMPP في هذا الموقع (الافتراضي C:\\xampp) أو اضبط المسار في الإعدادات → متقدم → أداة التقارير."
|
||||||
|
);
|
||||||
|
public static string MsgReportDeployFailed(string detail) => T(
|
||||||
|
$"Le déploiement de l'outil Report a échoué :\n\n{detail}\n\nLa version PROSERVE est installée mais l'onglet Statistiques pourrait être désynchronisé. Tu peux retenter via Paramètres → Avancés → Re-déployer le Report.",
|
||||||
|
$"Report tool deploy failed:\n\n{detail}\n\nThe PROSERVE version is installed but the Reports tab might be out of sync. You can retry via Settings → Advanced → Re-deploy Report.",
|
||||||
|
$"报告工具部署失败:\n\n{detail}\n\nPROSERVE 版本已安装,但报告选项卡可能不同步。您可以通过 设置 → 高级 → 重新部署报告 重试。",
|
||||||
|
$"การปรับใช้เครื่องมือรายงานล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วแต่แท็บรายงานอาจไม่ซิงค์ คุณสามารถลองใหม่ผ่าน ตั้งค่า → ขั้นสูง → ปรับใช้รายงานใหม่",
|
||||||
|
$"فشل نشر أداة التقارير:\n\n{detail}\n\nنسخة PROSERVE مثبتة لكن علامة التبويب التقارير قد لا تكون متزامنة. يمكنك إعادة المحاولة عبر الإعدادات → متقدم → إعادة نشر التقرير."
|
||||||
|
);
|
||||||
|
|
||||||
|
public static string SettingsReportTool => T("OUTIL REPORT (XAMPP htdocs)", "REPORT TOOL (XAMPP htdocs)", "报告工具 (XAMPP htdocs)", "เครื่องมือรายงาน (XAMPP htdocs)", "أداة التقارير (XAMPP htdocs)");
|
||||||
|
public static string SettingsReportHtdocs => T("Racine XAMPP htdocs", "XAMPP htdocs root", "XAMPP htdocs 根目录", "รากของ XAMPP htdocs", "جذر XAMPP htdocs");
|
||||||
|
public static string SettingsReportFolder => T("Nom du dossier de déploiement", "Deploy folder name", "部署文件夹名称", "ชื่อโฟลเดอร์ปรับใช้", "اسم مجلد النشر");
|
||||||
|
public static string SettingsReportAutoDeploy => T(
|
||||||
|
"Déployer automatiquement l'outil Report à l'install d'une nouvelle version",
|
||||||
|
"Automatically deploy the Report tool when installing a new version",
|
||||||
|
"安装新版本时自动部署报告工具",
|
||||||
|
"ปรับใช้เครื่องมือรายงานโดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่",
|
||||||
|
"نشر أداة التقارير تلقائياً عند تثبيت نسخة جديدة"
|
||||||
|
);
|
||||||
|
public static string SettingsReportRedeploy => T("📂 Re-déployer le Report maintenant", "📂 Re-deploy Report now", "📂 立即重新部署报告", "📂 ปรับใช้รายงานใหม่เดี๋ยวนี้", "📂 إعادة نشر التقرير الآن");
|
||||||
|
|
||||||
|
// ---- Backups Report tool ----
|
||||||
|
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
|
||||||
|
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");
|
||||||
|
public static string SettingsReportRevert => T("↶ Revert", "↶ Revert", "↶ 还原", "↶ ย้อนกลับ", "↶ استرجاع");
|
||||||
|
public static string SettingsReportNoBackups => T(
|
||||||
|
"Aucun backup pour l'instant. Le premier sera créé au prochain deploy.",
|
||||||
|
"No backups yet. The first one will be created on the next deploy.",
|
||||||
|
"暂无备份。下次部署时将创建第一个。",
|
||||||
|
"ยังไม่มีสำรองข้อมูล จะสร้างครั้งแรกในการปรับใช้ครั้งถัดไป",
|
||||||
|
"لا توجد نسخ احتياطية بعد. سيتم إنشاء أول نسخة عند النشر التالي."
|
||||||
|
);
|
||||||
|
public static string SettingsReportBackupSize(string size) => T(
|
||||||
|
$"taille : {size}", $"size: {size}", $"大小:{size}", $"ขนาด: {size}", $"الحجم: {size}"
|
||||||
|
);
|
||||||
|
public static string MsgBoxRevertReport => T("Revert Outil Report", "Revert Report tool", "还原报告工具", "ย้อนกลับเครื่องมือรายงาน", "استرجاع أداة التقارير");
|
||||||
|
public static string MsgRevertReportConfirm(string date) => T(
|
||||||
|
$"Revenir à la version du {date} ?\n\nLa version actuellement déployée sera elle-même sauvegardée comme nouveau backup, donc tu pourras y revenir si besoin.",
|
||||||
|
$"Revert to the version from {date}?\n\nThe currently deployed version will itself be saved as a new backup, so you can switch back if needed.",
|
||||||
|
$"还原到 {date} 的版本?\n\n当前已部署的版本将作为新备份保存,因此您可以在需要时切换回去。",
|
||||||
|
$"ย้อนกลับไปยังเวอร์ชันของ {date}?\n\nเวอร์ชันที่ปรับใช้อยู่ในปัจจุบันจะถูกบันทึกเป็นสำรองใหม่ ดังนั้นคุณสามารถสลับกลับได้หากต้องการ",
|
||||||
|
$"العودة إلى نسخة {date}؟\n\nسيتم حفظ النسخة المنشورة حالياً كنسخة احتياطية جديدة، لذا يمكنك التبديل إليها إذا لزم الأمر."
|
||||||
|
);
|
||||||
|
public static string MsgRevertReportSuccess(string date) => T(
|
||||||
|
$"✓ Reverted vers la version du {date}",
|
||||||
|
$"✓ Reverted to version from {date}",
|
||||||
|
$"✓ 已还原到 {date} 的版本",
|
||||||
|
$"✓ ย้อนกลับเป็นเวอร์ชันของ {date} แล้ว",
|
||||||
|
$"✓ تم الاسترجاع إلى نسخة {date}"
|
||||||
|
);
|
||||||
|
public static string MsgMigrationFailed(string detail) => T(
|
||||||
|
$"Une migration de la base de données a échoué :\n\n{detail}\n\nLa version a été extraite mais la base n'a PAS été modifiée (rollback automatique).\nVérifie que XAMPP est démarré et que les paramètres de connexion sont corrects (Settings → Base de données), puis clique « Rejouer les migrations » dans Settings.",
|
||||||
|
$"A database migration failed:\n\n{detail}\n\nThe version was extracted but the database was NOT modified (automatic rollback).\nMake sure XAMPP is running and the connection settings are correct (Settings → Database), then click « Replay migrations » in Settings.",
|
||||||
|
$"数据库迁移失败:\n\n{detail}\n\n版本已解压,但数据库未被修改(自动回滚)。\n请确保 XAMPP 正在运行并且连接设置正确(设置 → 数据库),然后点击设置中的「重新执行迁移」。",
|
||||||
|
$"การโยกย้ายฐานข้อมูลล้มเหลว:\n\n{detail}\n\nเวอร์ชันถูกแตกแล้วแต่ฐานข้อมูลไม่ถูกแก้ไข (rollback อัตโนมัติ)\nตรวจสอบว่า XAMPP กำลังทำงานและการตั้งค่าการเชื่อมต่อถูกต้อง (ตั้งค่า → ฐานข้อมูล) จากนั้นคลิก « เรียกใช้การโยกย้ายอีกครั้ง » ในการตั้งค่า",
|
||||||
|
$"فشلت ترقية قاعدة البيانات:\n\n{detail}\n\nتم استخراج النسخة لكن قاعدة البيانات لم تُعدَّل (rollback تلقائي).\nتأكد من تشغيل XAMPP وأن إعدادات الاتصال صحيحة (الإعدادات → قاعدة البيانات)، ثم انقر « إعادة تشغيل الترقيات » في الإعدادات."
|
||||||
|
);
|
||||||
|
|
||||||
public static string StatusPreparingDownload(string version) => T(
|
public static string StatusPreparingDownload(string version) => T(
|
||||||
$"Préparation du téléchargement v{version}…",
|
$"Préparation du téléchargement v{version}…",
|
||||||
$"Preparing download for v{version}…",
|
$"Preparing download for v{version}…",
|
||||||
|
|||||||
313
src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs
Normal file
313
src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MySqlConnector;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implémentation MySQL/MariaDB du service de migration.
|
||||||
|
/// Workflow :
|
||||||
|
/// <code>
|
||||||
|
/// 1. CREATE TABLE IF NOT EXISTS _launcher_migrations (filename, applied_at, checksum, duration_ms)
|
||||||
|
/// 2. SELECT déjà-appliquées
|
||||||
|
/// 3. Liste {installFolder}/_migrations/*.sql triées alphabétiquement
|
||||||
|
/// 4. Pour chaque non-appliquée :
|
||||||
|
/// START TRANSACTION
|
||||||
|
/// Exécute le script (peut contenir plusieurs statements séparés par ;)
|
||||||
|
/// INSERT INTO _launcher_migrations
|
||||||
|
/// COMMIT
|
||||||
|
/// Si exception → ROLLBACK + abort + reporte l'erreur
|
||||||
|
/// 5. Pour chaque appliquée dont le checksum a changé : log warning (script modifié après publication)
|
||||||
|
/// </code>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DatabaseMigrationService : IDatabaseMigrationService
|
||||||
|
{
|
||||||
|
private const string TrackingTable = "_launcher_migrations";
|
||||||
|
|
||||||
|
private readonly Func<DatabaseConfig> _configProvider;
|
||||||
|
private readonly Func<string?> _passwordProvider;
|
||||||
|
private readonly ILogger<DatabaseMigrationService> _logger;
|
||||||
|
|
||||||
|
public DatabaseMigrationService(
|
||||||
|
Func<DatabaseConfig> configProvider,
|
||||||
|
Func<string?> passwordProvider,
|
||||||
|
ILogger<DatabaseMigrationService> logger)
|
||||||
|
{
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_passwordProvider = passwordProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> TestConnectionAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
await using var cmd = new MySqlCommand("SELECT 1", conn);
|
||||||
|
await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MigrationResult> ApplyMigrationsAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<MigrationProgress>? progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var migrationsDir = Path.Combine(installFolder, "_migrations");
|
||||||
|
if (!Directory.Exists(migrationsDir))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No _migrations/ directory in {Folder}, skipping DB migration step", installFolder);
|
||||||
|
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste les .sql, ignore les autres extensions (README, etc.)
|
||||||
|
var files = Directory.EnumerateFiles(migrationsDir, "*.sql", SearchOption.TopDirectoryOnly)
|
||||||
|
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (files.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("_migrations/ exists but empty in {Folder}", installFolder);
|
||||||
|
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var applied = await LoadAppliedAsync(conn, ct).ConfigureAwait(false);
|
||||||
|
var outcomes = new List<MigrationOutcome>();
|
||||||
|
|
||||||
|
for (int i = 0; i < files.Count; i++)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var filepath = files[i];
|
||||||
|
var filename = Path.GetFileName(filepath);
|
||||||
|
progress?.Report(new MigrationProgress(i, files.Count, filename));
|
||||||
|
|
||||||
|
var sql = await File.ReadAllTextAsync(filepath, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||||
|
var checksum = ComputeSha256(sql);
|
||||||
|
|
||||||
|
if (applied.TryGetValue(filename, out var prevChecksum))
|
||||||
|
{
|
||||||
|
if (!string.Equals(prevChecksum, checksum, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Détection d'une modif après-publication : on skip mais on log fort.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Migration {File} was modified after being applied (stored sha256={Stored}, current={Now}). " +
|
||||||
|
"Ignored to preserve historical truth — write a NEW migration file instead.",
|
||||||
|
filename, prevChecksum[..12], checksum[..12]);
|
||||||
|
}
|
||||||
|
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Skipped, 0, null));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
await using var tx = await conn.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ExecuteScriptAsync(conn, tx, sql, ct).ConfigureAwait(false);
|
||||||
|
await using var insertCmd = new MySqlCommand(
|
||||||
|
$"INSERT INTO {TrackingTable} (filename, applied_at, checksum, duration_ms) VALUES (@f, NOW(), @c, @d)",
|
||||||
|
conn, tx);
|
||||||
|
insertCmd.Parameters.AddWithValue("@f", filename);
|
||||||
|
insertCmd.Parameters.AddWithValue("@c", checksum);
|
||||||
|
insertCmd.Parameters.AddWithValue("@d", sw.ElapsedMilliseconds);
|
||||||
|
await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||||
|
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||||
|
sw.Stop();
|
||||||
|
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Applied, sw.ElapsedMilliseconds, null));
|
||||||
|
_logger.LogInformation("Migration {File} applied in {Ms} ms", filename, sw.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
try { await tx.RollbackAsync(ct).ConfigureAwait(false); } catch { /* swallow */ }
|
||||||
|
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Failed, sw.ElapsedMilliseconds, ex.Message));
|
||||||
|
_logger.LogError(ex, "Migration {File} FAILED after {Ms} ms — DB state rolled back", filename, sw.ElapsedMilliseconds);
|
||||||
|
progress?.Report(new MigrationProgress(i + 1, files.Count, filename));
|
||||||
|
return new MigrationResult(outcomes, AllSucceeded: false, FailureMessage: $"{filename} : {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report(new MigrationProgress(files.Count, files.Count, string.Empty));
|
||||||
|
return new MigrationResult(outcomes, AllSucceeded: true, FailureMessage: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var list = new List<AppliedMigration>();
|
||||||
|
await using var cmd = new MySqlCommand(
|
||||||
|
$"SELECT filename, applied_at, checksum, duration_ms FROM {TrackingTable} ORDER BY id",
|
||||||
|
conn);
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
list.Add(new AppliedMigration(
|
||||||
|
reader.GetString(0),
|
||||||
|
DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetInt64(3)));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- helpers -----
|
||||||
|
|
||||||
|
private async Task<MySqlConnection> OpenAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
var pwd = _passwordProvider() ?? string.Empty;
|
||||||
|
var csb = new MySqlConnectionStringBuilder
|
||||||
|
{
|
||||||
|
Server = cfg.Host,
|
||||||
|
Port = cfg.Port,
|
||||||
|
UserID = cfg.User,
|
||||||
|
Password = pwd,
|
||||||
|
Database = cfg.Database,
|
||||||
|
// Désactive le pooling : on ouvre une connexion par opération courte,
|
||||||
|
// pas besoin de garder un pool ouvert sur la machine du user.
|
||||||
|
Pooling = false,
|
||||||
|
ConnectionTimeout = 10,
|
||||||
|
DefaultCommandTimeout = 600, // 10 min : laisse de la marge pour les grosses migrations
|
||||||
|
AllowUserVariables = true,
|
||||||
|
};
|
||||||
|
var conn = new MySqlConnection(csb.ConnectionString);
|
||||||
|
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
|
||||||
|
{
|
||||||
|
const string ddl = $"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {TrackingTable} (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
applied_at DATETIME NOT NULL,
|
||||||
|
checksum CHAR(64) NOT NULL,
|
||||||
|
duration_ms BIGINT NOT NULL DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
""";
|
||||||
|
await using var cmd = new MySqlCommand(ddl, conn);
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Dictionary<string, string>> LoadAppliedAsync(MySqlConnection conn, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
await using var cmd = new MySqlCommand($"SELECT filename, checksum FROM {TrackingTable}", conn);
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||||
|
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||||
|
map[reader.GetString(0)] = reader.GetString(1);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MySqlCommand exécute UNE commande à la fois. Pour un fichier .sql contenant
|
||||||
|
/// plusieurs statements séparés par <c>;</c>, on parse manuellement (en respectant
|
||||||
|
/// les chaînes / commentaires) et on exécute statement-par-statement dans la
|
||||||
|
/// même transaction. Évite la dépendance à <c>MySqlScript</c> et permet de
|
||||||
|
/// supporter <see cref="CancellationToken"/> proprement.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task ExecuteScriptAsync(MySqlConnection conn, MySqlTransaction tx, string sql, CancellationToken ct)
|
||||||
|
{
|
||||||
|
foreach (var stmt in SplitStatements(sql))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var trimmed = stmt.Trim();
|
||||||
|
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||||
|
// Skip si juste un commentaire ou une ligne blanche
|
||||||
|
if (IsAllCommentsOrWhitespace(trimmed)) continue;
|
||||||
|
await using var cmd = new MySqlCommand(trimmed, conn, tx);
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsAllCommentsOrWhitespace(string s)
|
||||||
|
{
|
||||||
|
// Heuristique simple : si après strip des commentaires il reste des trucs non-blancs.
|
||||||
|
var stripped = System.Text.RegularExpressions.Regex.Replace(s, @"(--[^\n]*)|(/\*[\s\S]*?\*/)", "");
|
||||||
|
return string.IsNullOrWhiteSpace(stripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splitter SQL minimaliste : sépare par <c>;</c> en respectant les chaînes
|
||||||
|
/// (' et "), les identifiants (`...`) et les commentaires (-- ... et /* ... */).
|
||||||
|
/// Suffisant pour la plupart des fichiers de migration courants ; pour des
|
||||||
|
/// triggers/procs avec DELIMITER, écrire chaque statement dans un fichier séparé.
|
||||||
|
/// </summary>
|
||||||
|
private static IEnumerable<string> SplitStatements(string sql)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
int i = 0;
|
||||||
|
char? quote = null;
|
||||||
|
bool inLineComment = false;
|
||||||
|
bool inBlockComment = false;
|
||||||
|
|
||||||
|
while (i < sql.Length)
|
||||||
|
{
|
||||||
|
char c = sql[i];
|
||||||
|
char next = i + 1 < sql.Length ? sql[i + 1] : '\0';
|
||||||
|
|
||||||
|
if (inLineComment)
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
if (c == '\n') inLineComment = false;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inBlockComment)
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
if (c == '*' && next == '/') { sb.Append(next); i += 2; inBlockComment = false; continue; }
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quote is not null)
|
||||||
|
{
|
||||||
|
sb.Append(c);
|
||||||
|
if (c == '\\' && i + 1 < sql.Length) { sb.Append(sql[i + 1]); i += 2; continue; }
|
||||||
|
if (c == quote) quote = null;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hors-quote, hors-commentaire
|
||||||
|
if (c == '-' && next == '-') { sb.Append(c).Append(next); i += 2; inLineComment = true; continue; }
|
||||||
|
if (c == '/' && next == '*') { sb.Append(c).Append(next); i += 2; inBlockComment = true; continue; }
|
||||||
|
if (c == '\'' || c == '"' || c == '`') { quote = c; sb.Append(c); i++; continue; }
|
||||||
|
|
||||||
|
if (c == ';')
|
||||||
|
{
|
||||||
|
yield return sb.ToString();
|
||||||
|
sb.Clear();
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.Append(c);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (sb.Length > 0)
|
||||||
|
{
|
||||||
|
var tail = sb.ToString().Trim();
|
||||||
|
if (tail.Length > 0) yield return tail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ComputeSha256(string text)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(text);
|
||||||
|
var hash = SHA256.HashData(bytes);
|
||||||
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/PSLauncher.Core/Migrations/DatabasePasswordProtector.cs
Normal file
36
src/PSLauncher.Core/Migrations/DatabasePasswordProtector.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chiffre / déchiffre le mot de passe MySQL stocké dans <see cref="PSLauncher.Models.DatabaseConfig.EncryptedPassword"/>
|
||||||
|
/// via DPAPI scope CurrentUser. Même mécanisme que pour la clé de license : un user
|
||||||
|
/// qui copierait le config.json sur une autre machine ne pourrait pas déchiffrer.
|
||||||
|
/// </summary>
|
||||||
|
public static class DatabasePasswordProtector
|
||||||
|
{
|
||||||
|
public static string? Protect(string? clearPassword)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(clearPassword)) return null;
|
||||||
|
var data = Encoding.UTF8.GetBytes(clearPassword);
|
||||||
|
var protectedBytes = ProtectedData.Protect(data, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
|
||||||
|
return Convert.ToBase64String(protectedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Unprotect(string? base64)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(base64)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var protectedBytes = Convert.FromBase64String(base64);
|
||||||
|
var data = ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
|
||||||
|
return Encoding.UTF8.GetString(data);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Cache déplacé sur autre machine / autre user → non déchiffrable
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/PSLauncher.Core/Migrations/IDatabaseMigrationService.cs
Normal file
57
src/PSLauncher.Core/Migrations/IDatabaseMigrationService.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
namespace PSLauncher.Core.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applique les scripts SQL livrés dans le sous-répertoire <c>_migrations/</c>
|
||||||
|
/// d'un build PROSERVE installé. Chaque script tournant en transaction, le
|
||||||
|
/// résultat est tracké dans une table <c>_launcher_migrations</c> pour qu'un
|
||||||
|
/// même script ne soit pas rejoué deux fois (mêmes garanties que Flyway/Doctrine).
|
||||||
|
/// </summary>
|
||||||
|
public interface IDatabaseMigrationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cherche <c>{installFolder}/_migrations/*.sql</c>, applique en ordre alphabétique
|
||||||
|
/// les scripts pas encore présents dans <c>_launcher_migrations</c>, et reporte la
|
||||||
|
/// progression. Retourne la liste détaillée des opérations (appliquées + skippées).
|
||||||
|
/// </summary>
|
||||||
|
Task<MigrationResult> ApplyMigrationsAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<MigrationProgress>? progress,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liste les migrations déjà appliquées en base. Utile pour la vue Settings.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test de connexion. Retourne null si OK, sinon le message d'erreur formaté.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> TestConnectionAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MigrationProgress(int Done, int Total, string CurrentFilename);
|
||||||
|
|
||||||
|
public sealed record MigrationResult(
|
||||||
|
IReadOnlyList<MigrationOutcome> Outcomes,
|
||||||
|
bool AllSucceeded,
|
||||||
|
string? FailureMessage)
|
||||||
|
{
|
||||||
|
public int AppliedCount => Outcomes.Count(o => o.Status == MigrationStatus.Applied);
|
||||||
|
public int SkippedCount => Outcomes.Count(o => o.Status == MigrationStatus.Skipped);
|
||||||
|
public int FailedCount => Outcomes.Count(o => o.Status == MigrationStatus.Failed);
|
||||||
|
public bool HasNoMigrationsFolder => !Outcomes.Any() && AllSucceeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record MigrationOutcome(
|
||||||
|
string Filename,
|
||||||
|
MigrationStatus Status,
|
||||||
|
long DurationMs,
|
||||||
|
string? Error);
|
||||||
|
|
||||||
|
public enum MigrationStatus { Skipped, Applied, Failed }
|
||||||
|
|
||||||
|
public sealed record AppliedMigration(
|
||||||
|
string Filename,
|
||||||
|
DateTime AppliedAt,
|
||||||
|
string Checksum,
|
||||||
|
long DurationMs);
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
<PackageReference Include="Polly" Version="8.4.2" />
|
<PackageReference Include="Polly" Version="8.4.2" />
|
||||||
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
|
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
|
||||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||||
|
<!-- MySQL client : MySqlConnector (BSD-3-Clause), pas Oracle MySql.Data (GPL) -->
|
||||||
|
<PackageReference Include="MySqlConnector" Version="2.3.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
68
src/PSLauncher.Core/ReportTool/IReportToolDeployer.cs
Normal file
68
src/PSLauncher.Core/ReportTool/IReportToolDeployer.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
namespace PSLauncher.Core.ReportTool;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Déploie l'outil Report (page web hébergée dans XAMPP) depuis le sous-dossier
|
||||||
|
/// <c>_report/</c> bundled dans chaque ZIP PROSERVE vers le htdocs local.
|
||||||
|
/// Conserve N backups horodatés permettant un revert manuel via la UI Settings.
|
||||||
|
/// </summary>
|
||||||
|
public interface IReportToolDeployer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Si <c>{installFolder}/_report/</c> existe, le copie vers
|
||||||
|
/// <c>{HtdocsRoot}/{FolderName}/</c> via un rename atomique :
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>copie récursive vers <c>{target}.new/</c></item>
|
||||||
|
/// <item>rename de l'existant vers <c>{target}.backup-{yyyyMMdd-HHmmss}/</c></item>
|
||||||
|
/// <item>rename <c>.new</c> → final</item>
|
||||||
|
/// <item>prune des vieux backups au-delà de <c>MaxBackups</c></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
Task<DeployResult> DeployAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<DeployProgress>? progress,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liste les backups horodatés disponibles dans <c>{HtdocsRoot}</c>, du plus
|
||||||
|
/// récent au plus ancien. Vide si aucun backup ou si htdocs introuvable.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Revient à un backup horodaté précis. Swap atomique : la version actuelle
|
||||||
|
/// est elle-même renommée en backup-{now} (donc le revert est lui-même
|
||||||
|
/// reversible), puis le backup choisi est renommé en version active.
|
||||||
|
/// </summary>
|
||||||
|
Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DeployStatus
|
||||||
|
{
|
||||||
|
/// <summary>Le ZIP n'avait pas de sous-dossier <c>_report/</c>, rien à faire.</summary>
|
||||||
|
SkippedNoSourceFolder,
|
||||||
|
/// <summary>HtdocsRoot configuré n'existe pas (XAMPP pas installé là).</summary>
|
||||||
|
XamppNotFound,
|
||||||
|
/// <summary>Erreur I/O pendant la copie ou le rename.</summary>
|
||||||
|
Failed,
|
||||||
|
/// <summary>Déploiement (ou revert) réussi.</summary>
|
||||||
|
Deployed,
|
||||||
|
/// <summary>Revert demandé sur un backup qui n'existe pas / plus.</summary>
|
||||||
|
BackupNotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DeployResult(
|
||||||
|
DeployStatus Status,
|
||||||
|
int FilesDeployed,
|
||||||
|
long BytesDeployed,
|
||||||
|
string TargetPath,
|
||||||
|
string? ErrorMessage = null);
|
||||||
|
|
||||||
|
public sealed record DeployProgress(int FilesDone, int FilesTotal, string CurrentFilename);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Métadonnées d'un backup horodaté présent dans <c>{HtdocsRoot}</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record BackupInfo(
|
||||||
|
string FolderName, // ProserveReport.backup-20260503-143022
|
||||||
|
DateTime TimestampUtc, // parsé depuis le suffixe du nom de dossier
|
||||||
|
long SizeBytes);
|
||||||
233
src/PSLauncher.Core/ReportTool/ReportToolDeployer.cs
Normal file
233
src/PSLauncher.Core/ReportTool/ReportToolDeployer.cs
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.ReportTool;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implémentation : copy récursif + atomic rename + backups horodatés.
|
||||||
|
/// L'atomicité du rename garantit qu'Apache (qui sert les fichiers en parallèle)
|
||||||
|
/// ne tombera jamais sur un état mi-déployé. Au pire il sert l'ancienne version
|
||||||
|
/// pendant 1ms le temps du rename, puis sert la nouvelle.
|
||||||
|
///
|
||||||
|
/// Backups : à chaque deploy, l'ancien dossier est conservé sous le nom
|
||||||
|
/// <c>{FolderName}.backup-yyyyMMdd-HHmmss</c>. On garde les <c>MaxBackups</c>
|
||||||
|
/// plus récents et on supprime les autres en best-effort. Permet le revert
|
||||||
|
/// manuel via Settings → Avancés → Outil Report.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReportToolDeployer : IReportToolDeployer
|
||||||
|
{
|
||||||
|
private const string SourceSubdir = "_report";
|
||||||
|
private const string BackupSuffix = ".backup-";
|
||||||
|
// Format trié-par-nom = trié-par-date (lexicographique == chronologique)
|
||||||
|
private const string TimestampFormat = "yyyyMMdd-HHmmss";
|
||||||
|
|
||||||
|
private readonly Func<ReportToolConfig> _configProvider;
|
||||||
|
private readonly ILogger<ReportToolDeployer> _logger;
|
||||||
|
|
||||||
|
public ReportToolDeployer(
|
||||||
|
Func<ReportToolConfig> configProvider,
|
||||||
|
ILogger<ReportToolDeployer> logger)
|
||||||
|
{
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeployResult> DeployAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<DeployProgress>? progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
var source = Path.Combine(installFolder, SourceSubdir);
|
||||||
|
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
|
||||||
|
|
||||||
|
if (!Directory.Exists(source))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No {Sub}/ directory in {Folder}, skipping report deploy", SourceSubdir, installFolder);
|
||||||
|
return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
|
||||||
|
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||||
|
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var stagingTarget = target + ".new";
|
||||||
|
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Nettoyage d'un éventuel résidu de tentative précédente (le .new orphelin)
|
||||||
|
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
|
||||||
|
|
||||||
|
// Copy avec progress
|
||||||
|
var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList();
|
||||||
|
int total = allFiles.Count;
|
||||||
|
long bytesTotal = 0;
|
||||||
|
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(stagingTarget);
|
||||||
|
int done = 0;
|
||||||
|
foreach (var srcFile in allFiles)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var rel = Path.GetRelativePath(source, srcFile);
|
||||||
|
var dstFile = Path.Combine(stagingTarget, rel);
|
||||||
|
var dstDir = Path.GetDirectoryName(dstFile);
|
||||||
|
if (!string.IsNullOrEmpty(dstDir) && !Directory.Exists(dstDir))
|
||||||
|
Directory.CreateDirectory(dstDir);
|
||||||
|
File.Copy(srcFile, dstFile, overwrite: true);
|
||||||
|
bytesTotal += new FileInfo(srcFile).Length;
|
||||||
|
done++;
|
||||||
|
progress?.Report(new DeployProgress(done, total, rel));
|
||||||
|
}
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Atomic swap : current → backup-{ts}, .new → current
|
||||||
|
if (Directory.Exists(target))
|
||||||
|
{
|
||||||
|
Directory.Move(target, backupTarget);
|
||||||
|
}
|
||||||
|
Directory.Move(stagingTarget, target);
|
||||||
|
|
||||||
|
// Prune des vieux backups (best-effort, ne bloque pas le retour)
|
||||||
|
PruneOldBackups(cfg);
|
||||||
|
|
||||||
|
_logger.LogInformation("Report tool deployed to {Target} ({Files} files, {Bytes} bytes), backup at {Backup}",
|
||||||
|
target, total, bytesTotal, backupTarget);
|
||||||
|
return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to deploy report tool to {Target}", target);
|
||||||
|
try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
var prefix = cfg.FolderName + BackupSuffix;
|
||||||
|
|
||||||
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
|
return Task.FromResult<IReadOnlyList<BackupInfo>>(Array.Empty<BackupInfo>());
|
||||||
|
|
||||||
|
var list = new List<BackupInfo>();
|
||||||
|
foreach (var dir in Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var name = Path.GetFileName(dir);
|
||||||
|
var tsPart = name.Substring(prefix.Length);
|
||||||
|
if (!DateTime.TryParseExact(tsPart, TimestampFormat, CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ts))
|
||||||
|
{
|
||||||
|
continue; // dossier non conforme, on ignore
|
||||||
|
}
|
||||||
|
long size = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||||
|
size += new FileInfo(f).Length;
|
||||||
|
}
|
||||||
|
catch { /* size best-effort */ }
|
||||||
|
list.Add(new BackupInfo(name, ts, size));
|
||||||
|
}
|
||||||
|
// Plus récent en premier
|
||||||
|
list.Sort((a, b) => b.TimestampUtc.CompareTo(a.TimestampUtc));
|
||||||
|
return Task.FromResult<IReadOnlyList<BackupInfo>>(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
var backupPath = Path.Combine(cfg.HtdocsRoot, backupFolderName);
|
||||||
|
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
|
||||||
|
|
||||||
|
if (!Directory.Exists(backupPath))
|
||||||
|
{
|
||||||
|
return new DeployResult(DeployStatus.BackupNotFound, 0, 0, target,
|
||||||
|
$"Le backup {backupFolderName} n'existe plus.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le revert lui-même crée un backup de l'état actuel, pour qu'on puisse
|
||||||
|
// revert le revert si besoin.
|
||||||
|
var newBackupForCurrent = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (Directory.Exists(target))
|
||||||
|
Directory.Move(target, newBackupForCurrent);
|
||||||
|
Directory.Move(backupPath, target);
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Compte les fichiers du backup restauré pour le retour
|
||||||
|
int files = 0;
|
||||||
|
long bytes = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var f in Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
files++;
|
||||||
|
bytes += new FileInfo(f).Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
|
||||||
|
PruneOldBackups(cfg);
|
||||||
|
_logger.LogInformation("Reverted to backup {Backup} ({Files} files), previous current saved as {New}",
|
||||||
|
backupFolderName, files, newBackupForCurrent);
|
||||||
|
return new DeployResult(DeployStatus.Deployed, files, bytes, target);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Revert to {Backup} failed", backupFolderName);
|
||||||
|
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Garde les <c>MaxBackups</c> plus récents, supprime les autres en best-effort.
|
||||||
|
/// Si <c>MaxBackups = 0</c>, supprime tous les backups (mode "no history").
|
||||||
|
/// </summary>
|
||||||
|
private void PruneOldBackups(ReportToolConfig cfg)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var prefix = cfg.FolderName + BackupSuffix;
|
||||||
|
var dirs = Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly)
|
||||||
|
.Select(d => new { Path = d, Name = Path.GetFileName(d) })
|
||||||
|
// Tri descending par nom = descending par date (yyyyMMdd-HHmmss est lexicographique)
|
||||||
|
.OrderByDescending(d => d.Name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
int keep = Math.Max(0, cfg.MaxBackups);
|
||||||
|
for (int i = keep; i < dirs.Count; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(dirs[i].Path, recursive: true);
|
||||||
|
_logger.LogDebug("Pruned old report backup {Dir}", dirs[i].Name);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Apache peut tenir un handle sur un fichier (logs ouverts, etc.).
|
||||||
|
// On retentera au prochain deploy. Pas bloquant.
|
||||||
|
_logger.LogDebug(ex, "Could not prune backup {Dir} (will retry next deploy)", dirs[i].Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Backup pruning skipped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ public sealed class LauncherSelfUpdater : ILauncherSelfUpdater
|
|||||||
IProgress<DownloadProgress>? progress,
|
IProgress<DownloadProgress>? progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
// 1. Localise le PSLauncher.exe en cours d'exécution
|
// 1. Localise le PS_Launcher.exe en cours d'exécution
|
||||||
var processModule = SysProcess.GetCurrentProcess().MainModule
|
var processModule = SysProcess.GetCurrentProcess().MainModule
|
||||||
?? throw new InvalidOperationException("Cannot locate current process module");
|
?? throw new InvalidOperationException("Cannot locate current process module");
|
||||||
var targetExe = processModule.FileName!;
|
var targetExe = processModule.FileName!;
|
||||||
@@ -64,20 +64,18 @@ public sealed class LauncherSelfUpdater : ILauncherSelfUpdater
|
|||||||
var downloadedPath = await _downloadManager.DownloadAsync(downloadJob, progress, ct).ConfigureAwait(false);
|
var downloadedPath = await _downloadManager.DownloadAsync(downloadJob, progress, ct).ConfigureAwait(false);
|
||||||
_logger.LogInformation("New launcher downloaded to {Path}", downloadedPath);
|
_logger.LogInformation("New launcher downloaded to {Path}", downloadedPath);
|
||||||
|
|
||||||
// 3. Localise PSLauncher.Updater.exe (à côté du target)
|
// 3. Localise PS_Launcher.Updater.exe (à côté du target)
|
||||||
var updaterPath = Path.Combine(installDir, "PSLauncher.Updater.exe");
|
var updaterPath = Path.Combine(installDir, "PS_Launcher.Updater.exe");
|
||||||
if (!File.Exists(updaterPath))
|
if (!File.Exists(updaterPath))
|
||||||
{
|
{
|
||||||
// Fallback : tente de copier l'updater depuis le dossier d'install vers un staging
|
|
||||||
// si jamais il a été supprimé. Pour la v1, on demande à l'utilisateur de réinstaller.
|
|
||||||
throw new FileNotFoundException(
|
throw new FileNotFoundException(
|
||||||
"PSLauncher.Updater.exe est manquant à côté de PSLauncher.exe. " +
|
"PS_Launcher.Updater.exe est manquant à côté de PS_Launcher.exe. " +
|
||||||
"Réinstalle le launcher depuis l'installeur officiel.",
|
"Réinstalle le launcher depuis l'installeur officiel.",
|
||||||
updaterPath);
|
updaterPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Copie le .new dans le staging dir si downloaded est ailleurs
|
// 4. Copie le .new dans le staging dir si downloaded est ailleurs
|
||||||
var newPath = Path.Combine(stagingDir, $"PSLauncher-{launcher.Version}.exe");
|
var newPath = Path.Combine(stagingDir, $"PS_Launcher-{launcher.Version}.exe");
|
||||||
if (!string.Equals(downloadedPath, newPath, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(downloadedPath, newPath, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
if (File.Exists(newPath)) File.Delete(newPath);
|
if (File.Exists(newPath)) File.Delete(newPath);
|
||||||
|
|||||||
@@ -28,7 +28,94 @@ public sealed class LocalConfig
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool VerifyDownloadHash { get; set; } = true;
|
public bool VerifyDownloadHash { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL de l'outil de reporting local (page web embarquée dans le launcher,
|
||||||
|
/// onglet "Report"). Par défaut pointe vers l'install standard ASTERION
|
||||||
|
/// dans XAMPP. Surchargeable dans Settings → Avancés.
|
||||||
|
/// </summary>
|
||||||
|
public string ReportUrl { get; set; } = "http://localhost/ProserveReport/";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL de la documentation locale (onglet "Documentation"). Vide par défaut :
|
||||||
|
/// l'utilisateur peut pointer vers une URL locale ou web. Si vide, l'onglet
|
||||||
|
/// affiche un placeholder avec les liens vers les fichiers .pptx du dossier
|
||||||
|
/// d'install du launcher.
|
||||||
|
/// </summary>
|
||||||
|
public string DocumentationUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
public LicenseConfig License { get; set; } = new();
|
public LicenseConfig License { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration du déploiement de l'outil Report (page web locale embarquée
|
||||||
|
/// dans l'onglet "Statistiques") vers la racine XAMPP htdocs.
|
||||||
|
/// Le launcher copie le dossier <c>_report/</c> bundled dans chaque ZIP
|
||||||
|
/// PROSERVE vers <c>{HtdocsRoot}\{FolderName}</c> à chaque install.
|
||||||
|
/// </summary>
|
||||||
|
public ReportToolConfig ReportTool { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connexion à la base MySQL locale (XAMPP) utilisée par PROSERVE pour ses
|
||||||
|
/// statistiques. Le launcher s'en sert pour appliquer les migrations bundled
|
||||||
|
/// dans <c>_migrations/</c> au moment de l'install d'une nouvelle version.
|
||||||
|
/// </summary>
|
||||||
|
public DatabaseConfig Database { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paramètres de connexion à la base MySQL locale. Les valeurs par défaut
|
||||||
|
/// correspondent à un XAMPP standard fraîchement installé. Override possible
|
||||||
|
/// via Settings → Base de données.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DatabaseConfig
|
||||||
|
{
|
||||||
|
public string Host { get; set; } = "localhost";
|
||||||
|
public uint Port { get; set; } = 3306;
|
||||||
|
public string User { get; set; } = "root";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mot de passe MySQL stocké chiffré DPAPI (CurrentUser scope) en base64.
|
||||||
|
/// Vide = pas de password (config XAMPP par défaut). NE PAS écrire en clair.
|
||||||
|
/// </summary>
|
||||||
|
public string? EncryptedPassword { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nom de la base de données qui contient les tables de PROSERVE.
|
||||||
|
/// Default "proserveapi" — c'est le nom utilisé par le script d'install ASTERION
|
||||||
|
/// qui provisionne XAMPP sur les machines clients. À surcharger seulement si la DB
|
||||||
|
/// a été créée sous un autre nom.
|
||||||
|
/// </summary>
|
||||||
|
public string Database { get; set; } = "proserveapi";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Si true, le launcher applique automatiquement les migrations SQL bundled
|
||||||
|
/// dans <c>_migrations/</c> de chaque ZIP, juste après l'extraction.
|
||||||
|
/// Sécurité : on n'applique JAMAIS sans cette opt-in (au cas où certains users
|
||||||
|
/// auraient une config DB exotique non gérée par le defaut).
|
||||||
|
/// </summary>
|
||||||
|
public bool AutoApplyMigrations { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paramètres de déploiement de l'outil Report local.
|
||||||
|
/// Le launcher cherche un sous-dossier <c>_report/</c> dans chaque ZIP PROSERVE
|
||||||
|
/// installé et le copie vers <c>{HtdocsRoot}\{FolderName}</c> via un rename
|
||||||
|
/// atomique (deploy en .new puis swap). L'URL de l'onglet Statistiques pointe
|
||||||
|
/// vers <c>http://localhost/{FolderName}/</c> ; les deux doivent matcher.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ReportToolConfig
|
||||||
|
{
|
||||||
|
public string HtdocsRoot { get; set; } = @"C:\xampp\htdocs";
|
||||||
|
public string FolderName { get; set; } = "ProserveReport";
|
||||||
|
public bool AutoDeploy { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nombre de backups horodatés à conserver dans htdocs après chaque deploy.
|
||||||
|
/// Format des dossiers : <c>{FolderName}.backup-yyyyMMdd-HHmmss</c>.
|
||||||
|
/// Permet le revert via Settings si le nouveau deploy a un bug visible.
|
||||||
|
/// 3 par défaut = couvre les dernières releases sans grossir trop le disque.
|
||||||
|
/// 0 = pas de backup (retourne au comportement v0.12.0).
|
||||||
|
/// </summary>
|
||||||
|
public int MaxBackups { get; set; } = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class LicenseConfig
|
public sealed class LicenseConfig
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<AssemblyName>PSLauncher.Updater</AssemblyName>
|
<AssemblyName>PS_Launcher.Updater</AssemblyName>
|
||||||
<RootNamespace>PSLauncher.Updater</RootNamespace>
|
<RootNamespace>PSLauncher.Updater</RootNamespace>
|
||||||
<Version>0.8.0</Version>
|
<Version>0.16.0</Version>
|
||||||
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
|
||||||
<Company>ASTERION VR</Company>
|
<Company>ASTERION VR</Company>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
|
|||||||
355
version/proserve-1.5.3/_migrations/0001_Init.sql
Normal file
355
version/proserve-1.5.3/_migrations/0001_Init.sql
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 0001_Init.sql — schéma initial PROSERVE (proserveapi).
|
||||||
|
--
|
||||||
|
-- Idempotent : tout est en IF NOT EXISTS / INSERT IGNORE / ADD CONSTRAINT IF
|
||||||
|
-- NOT EXISTS, donc rejouable sur une DB déjà peuplée sans générer d'erreur.
|
||||||
|
-- Cible MariaDB 10.0.2+ (XAMPP standard depuis longtemps).
|
||||||
|
--
|
||||||
|
-- Note : pas de START TRANSACTION / COMMIT à l'intérieur du fichier — le
|
||||||
|
-- launcher (PSLauncher.Core.Migrations.DatabaseMigrationService) wrappe déjà
|
||||||
|
-- chaque .sql dans sa propre transaction pour pouvoir rollback proprement.
|
||||||
|
--
|
||||||
|
-- Source d'origine : dump phpMyAdmin 5.2.1 / MariaDB 10.4.32 (5 fév 2026).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||||
|
SET time_zone = "+00:00";
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS `proserveapi`
|
||||||
|
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
USE `proserveapi`;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- TABLES
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `participates` (
|
||||||
|
`userId` int(11) NOT NULL COMMENT 'user id',
|
||||||
|
`sessionId` int(11) NOT NULL COMMENT 'session id',
|
||||||
|
`score` int(11) NOT NULL DEFAULT 0 COMMENT 'user score in the session',
|
||||||
|
`firePrecision` float NOT NULL DEFAULT 0 COMMENT 'user precision during the session',
|
||||||
|
`reactionTime` float NOT NULL DEFAULT 0 COMMENT 'average reaction time of the user in this session',
|
||||||
|
`nbEnemyHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of enemies hit',
|
||||||
|
`nbCivilsHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of civils hit',
|
||||||
|
`damageTaken` int(11) NOT NULL DEFAULT 0 COMMENT 'nb hits received',
|
||||||
|
`endStatus` int(1) NOT NULL DEFAULT 1 COMMENT 'user status at the end of the session (0=>dead, 1=>alive, add 2 for injured ?)',
|
||||||
|
`avatar` varchar(255) NOT NULL COMMENT 'avatar of user for this session',
|
||||||
|
`weapon` varchar(255) NOT NULL COMMENT 'weapon of user for this session',
|
||||||
|
`role` int(11) NOT NULL DEFAULT 0 COMMENT 'user role in the session',
|
||||||
|
`results` text NOT NULL DEFAULT '\'\'' COMMENT 'user''s results (objectives) in session (JSON format)'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `reacteventmodes` (
|
||||||
|
`id` int(11) NOT NULL COMMENT 'id',
|
||||||
|
`displayName` varchar(255) NOT NULL COMMENT 'name',
|
||||||
|
`description` varchar(255) NOT NULL COMMENT 'description'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `reactevents` (
|
||||||
|
`id` int(11) NOT NULL COMMENT 'event id',
|
||||||
|
`srcEventSessionId` int(11) NOT NULL COMMENT 'session id',
|
||||||
|
`srcEventIndex` int(11) NOT NULL COMMENT 'id of the triggering event that initiated this reaction event',
|
||||||
|
`reactType` int(11) NOT NULL COMMENT 'react event type',
|
||||||
|
`reactMode` int(11) NOT NULL DEFAULT 0 COMMENT 'react event mode (penetration, ricochet, termination)',
|
||||||
|
`hitUserId` int(11) NOT NULL DEFAULT 0 COMMENT 'id of the user that has been hit (0 if enemy/civil/env/game)',
|
||||||
|
`hitTargetName` varchar(255) NOT NULL COMMENT 'target hit by the event',
|
||||||
|
`hitBoneName` varchar(255) NOT NULL COMMENT 'bone hit on the target',
|
||||||
|
`damage` float NOT NULL DEFAULT 0 COMMENT 'damage points for the event',
|
||||||
|
`targetKilled` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'did target got killed by the event ?',
|
||||||
|
`objectHitLocationX` float NOT NULL DEFAULT 0 COMMENT 'when hitting a target (object) record X position on texture',
|
||||||
|
`objectHitLocationY` float NOT NULL DEFAULT 0 COMMENT 'when hitting a target (object) record Y position on texture',
|
||||||
|
`objectHitTagLocation` varchar(255) NOT NULL COMMENT 'tag on the hit object',
|
||||||
|
`hitPrecision` float NOT NULL DEFAULT 1 COMMENT 'precision of the hit',
|
||||||
|
`distance` float NOT NULL DEFAULT 0 COMMENT 'distance from source to target hit',
|
||||||
|
`reactTime` float NOT NULL DEFAULT 0 COMMENT 'reaction time (for challenges)',
|
||||||
|
`timeStamp` float NOT NULL DEFAULT 0 COMMENT 'timeStamp react shot was registered'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `reacteventtypes` (
|
||||||
|
`id` int(11) NOT NULL COMMENT 'type id',
|
||||||
|
`displayName` varchar(255) NOT NULL COMMENT 'name',
|
||||||
|
`description` varchar(255) NOT NULL COMMENT 'description'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `sessions` (
|
||||||
|
`id` int(11) NOT NULL COMMENT 'session id',
|
||||||
|
`sessionType` int(11) NOT NULL COMMENT 'session type',
|
||||||
|
`sessionName` varchar(255) NOT NULL COMMENT 'session name',
|
||||||
|
`sessionDate` datetime NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the session',
|
||||||
|
`mapName` varchar(255) NOT NULL COMMENT 'map of the session',
|
||||||
|
`scenarioName` varchar(255) NOT NULL COMMENT 'scenario name that was played',
|
||||||
|
`success` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'was the session successful (scenario objective reached) ?',
|
||||||
|
`timeToFinish` float NOT NULL DEFAULT 0 COMMENT 'session duration',
|
||||||
|
`score` int(11) NOT NULL DEFAULT 0 COMMENT 'user score',
|
||||||
|
`nbEnemyHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of enemies hit',
|
||||||
|
`nbCivilsHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of civils saved',
|
||||||
|
`damageTaken` float NOT NULL DEFAULT 0 COMMENT 'total damage taken',
|
||||||
|
`replayFileName` varchar(255) NOT NULL COMMENT 'filename of the session replay'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `sessiontypes` (
|
||||||
|
`id` int(11) NOT NULL COMMENT 'session type id',
|
||||||
|
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the session type',
|
||||||
|
`description` varchar(255) NOT NULL COMMENT 'description of the session type'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `triggerevents` (
|
||||||
|
`sessionId` int(11) NOT NULL COMMENT 'id of the user session during which the event was triggered',
|
||||||
|
`indexCount` int(11) NOT NULL COMMENT 'index of event in session',
|
||||||
|
`srcUserId` int(11) NOT NULL COMMENT 'id of the user that triggered the shot',
|
||||||
|
`type` int(11) NOT NULL COMMENT 'type of event',
|
||||||
|
`successful` tinyint(1) NOT NULL COMMENT 'was the shot (or triggered event) successful (hit a target) ?',
|
||||||
|
`timeStamp` float NOT NULL COMMENT 'time the event occurred at'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `triggereventtypes` (
|
||||||
|
`id` int(11) NOT NULL COMMENT 'event type id',
|
||||||
|
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the event type',
|
||||||
|
`description` varchar(255) NOT NULL COMMENT 'description of the event type'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `userroles` (
|
||||||
|
`id` int(11) NOT NULL COMMENT 'session type id',
|
||||||
|
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the session type',
|
||||||
|
`description` varchar(255) NOT NULL COMMENT 'description of the session type'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `users` (
|
||||||
|
`id` int(11) NOT NULL COMMENT 'user id',
|
||||||
|
`username` varchar(255) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL COMMENT 'username',
|
||||||
|
`password` varchar(255) NOT NULL COMMENT 'password',
|
||||||
|
`firstName` varchar(255) NOT NULL COMMENT 'first name',
|
||||||
|
`lastName` varchar(255) NOT NULL COMMENT 'last name',
|
||||||
|
`created` timestamp NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the creation of the user account',
|
||||||
|
`leftHanded` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'is user left handed?',
|
||||||
|
`maleGender` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'is user a man (true) or a woman?',
|
||||||
|
`charSkinAssetName` varchar(255) NOT NULL COMMENT 'name of the asset for the character skin',
|
||||||
|
`weaponAssetName` varchar(255) NOT NULL COMMENT 'name of the asset for the weapon',
|
||||||
|
`lastConnection` datetime NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the last connection',
|
||||||
|
`avgPrecision` float NOT NULL COMMENT 'average precision when firing',
|
||||||
|
`avgReaction` float NOT NULL COMMENT 'average reaction time',
|
||||||
|
`avgFault` float NOT NULL COMMENT 'average error rate',
|
||||||
|
`avgRapidity` float NOT NULL COMMENT 'average rapidity time',
|
||||||
|
`size` int(6) NOT NULL DEFAULT 175
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `userstatus` (
|
||||||
|
`id` int(11) NOT NULL COMMENT 'status id',
|
||||||
|
`displayName` varchar(255) NOT NULL COMMENT 'display name for status (alive, dead)',
|
||||||
|
`description` varchar(255) NOT NULL COMMENT 'description'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- DONNÉES DE RÉFÉRENCE (INSERT IGNORE = skip silencieux si PK déjà présente)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `reacteventmodes` (`id`, `displayName`, `description`) VALUES
|
||||||
|
(0, 'Penetration', ''),
|
||||||
|
(1, 'Ricochet', ''),
|
||||||
|
(2, 'Termination', '');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `reacteventtypes` (`id`, `displayName`, `description`) VALUES
|
||||||
|
(0, 'EnemyHit', 'An enemy has been hit'),
|
||||||
|
(1, 'CivilianHit', 'A civilian has been hit'),
|
||||||
|
(2, 'PoliceHit', 'Another player has been hit by himself'),
|
||||||
|
(3, 'ObjectHit', 'A target (object) has been hit'),
|
||||||
|
(4, 'PaperTargetHit', ''),
|
||||||
|
(5, 'TargetHit', ''),
|
||||||
|
(6, 'DeadBodyHit', 'An already-dead body has been hit');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `sessiontypes` (`id`, `displayName`, `description`) VALUES
|
||||||
|
(0, 'FireRange', ''),
|
||||||
|
(1, 'Challenge', ''),
|
||||||
|
(2, 'Protect', ''),
|
||||||
|
(3, 'De-Escalation', ''),
|
||||||
|
(4, 'Terrorism', ''),
|
||||||
|
(5, 'FireExtinction', ''),
|
||||||
|
(6, 'Rescue', ''),
|
||||||
|
(7, 'LongRange', '');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `triggereventtypes` (`id`, `displayName`, `description`) VALUES
|
||||||
|
(0, 'Fire', 'Player has fired');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `userroles` (`id`, `displayName`, `description`) VALUES
|
||||||
|
(0, 'Police', 'Player is Police/SWAT'),
|
||||||
|
(1, 'Enemy', 'Player is an Enemy'),
|
||||||
|
(2, 'Civil', 'Player is a Civilian'),
|
||||||
|
(3, 'IA', 'Role for IA and NPC');
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `userstatus` (`id`, `displayName`, `description`) VALUES
|
||||||
|
(0, 'Alive', 'avatar is alive'),
|
||||||
|
(1, 'Lightly Injured', 'avatar has been slightly injured'),
|
||||||
|
(2, 'Seriously Injured', 'avatar has been seriously injured'),
|
||||||
|
(3, 'Dead', 'avatar is dead');
|
||||||
|
|
||||||
|
-- Comptes utilisateurs initiaux (system + 2 démos). INSERT IGNORE évite
|
||||||
|
-- d'écraser ce que le client aurait déjà créé si la table users contient
|
||||||
|
-- des id=0/1/2 différents.
|
||||||
|
INSERT IGNORE INTO `users` (`id`, `username`, `password`, `firstName`, `lastName`, `created`, `leftHanded`, `maleGender`, `charSkinAssetName`, `weaponAssetName`, `lastConnection`, `avgPrecision`, `avgReaction`, `avgFault`, `avgRapidity`, `size`) VALUES
|
||||||
|
(0, 'game', '', '', '', '2023-01-24 10:52:35', 0, 0, '', '', '2023-01-24 11:52:35', 0, 0, 0, 0, 0),
|
||||||
|
(1, 'Jerome', 'Q29udHJvbDM1', 'Jerome', '', '2026-02-05 18:06:06', 0, 1, 'PS_PAWN_VR_V2_POLICE_ARABIC_C', 'Weapon_G17_C', '2026-02-05 12:31:59', 0, 0, 0, 0, 171),
|
||||||
|
(2, 'Jerome2', 'Q29udHJvbDM1', 'Jerome2', '', '2026-02-05 18:06:24', 0, 1, 'PS_PAWN_VR_V2_POLICE_BLACK_C', 'Weapon_G17_C', '2026-02-05 12:32:23', 0, 0, 0, 0, 175);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- INDEX & CONTRAINTES (toutes en IF NOT EXISTS pour idempotence)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- participates : PK composite (userId, sessionId) + FKs
|
||||||
|
ALTER TABLE `participates`
|
||||||
|
ADD PRIMARY KEY IF NOT EXISTS (`userId`,`sessionId`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_Participates_SessionId` (`sessionId`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_Participates_UserStatus` (`endStatus`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_Participates_UserRole` (`role`);
|
||||||
|
|
||||||
|
ALTER TABLE `reacteventmodes`
|
||||||
|
ADD PRIMARY KEY IF NOT EXISTS (`id`);
|
||||||
|
|
||||||
|
ALTER TABLE `reactevents`
|
||||||
|
ADD PRIMARY KEY IF NOT EXISTS (`id`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_React_TypeId` (`reactType`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_React_HitUserId` (`hitUserId`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_React_EventIndex` (`srcEventIndex`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_React_SessionId` (`srcEventSessionId`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_React_Mode` (`reactMode`);
|
||||||
|
|
||||||
|
ALTER TABLE `reacteventtypes`
|
||||||
|
ADD PRIMARY KEY IF NOT EXISTS (`id`);
|
||||||
|
|
||||||
|
ALTER TABLE `sessions`
|
||||||
|
ADD PRIMARY KEY IF NOT EXISTS (`id`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_sessionType` (`sessionType`) USING BTREE;
|
||||||
|
|
||||||
|
ALTER TABLE `sessiontypes`
|
||||||
|
ADD PRIMARY KEY IF NOT EXISTS (`id`);
|
||||||
|
|
||||||
|
ALTER TABLE `triggerevents`
|
||||||
|
ADD PRIMARY KEY IF NOT EXISTS (`indexCount`,`sessionId`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_TriggerEvent_SrcUserId` (`srcUserId`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_TriggerEvent_Type` (`type`),
|
||||||
|
ADD KEY IF NOT EXISTS `FK_TriggerEvent_UserId` (`sessionId`);
|
||||||
|
|
||||||
|
ALTER TABLE `triggereventtypes`
|
||||||
|
ADD PRIMARY KEY IF NOT EXISTS (`id`);
|
||||||
|
|
||||||
|
ALTER TABLE `userroles`
|
||||||
|
ADD PRIMARY KEY IF NOT EXISTS (`id`);
|
||||||
|
|
||||||
|
ALTER TABLE `users`
|
||||||
|
ADD PRIMARY KEY IF NOT EXISTS (`id`),
|
||||||
|
ADD UNIQUE KEY IF NOT EXISTS `username` (`username`);
|
||||||
|
|
||||||
|
ALTER TABLE `userstatus`
|
||||||
|
ADD PRIMARY KEY IF NOT EXISTS (`id`);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- AUTO_INCREMENT (idempotent : MariaDB ignore la valeur si MAX(id) la dépasse)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE `reactevents`
|
||||||
|
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'event id';
|
||||||
|
|
||||||
|
ALTER TABLE `sessions`
|
||||||
|
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'session id';
|
||||||
|
|
||||||
|
ALTER TABLE `users`
|
||||||
|
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'user id';
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- FOREIGN KEYS
|
||||||
|
-- Syntaxe : ADD FOREIGN KEY IF NOT EXISTS `name` (...) REFERENCES ...
|
||||||
|
-- (NOTE : en MariaDB, le `CONSTRAINT name FOREIGN KEY` n'accepte PAS de clause
|
||||||
|
-- `IF NOT EXISTS` ; en revanche `FOREIGN KEY IF NOT EXISTS name` est supporté
|
||||||
|
-- depuis 10.0.2 et est strictement équivalent côté résultat.)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE `participates`
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_SessionId` (`sessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE,
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserId` (`userId`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserRole` (`role`) REFERENCES `userroles` (`id`),
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserStatus` (`endStatus`) REFERENCES `userstatus` (`id`);
|
||||||
|
|
||||||
|
ALTER TABLE `reactevents`
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_React_HitUserId` (`hitUserId`) REFERENCES `users` (`id`),
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_React_Mode` (`reactMode`) REFERENCES `reacteventmodes` (`id`),
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_React_SessionId` (`srcEventSessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE,
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_React_TypeId` (`reactType`) REFERENCES `reacteventtypes` (`id`);
|
||||||
|
|
||||||
|
ALTER TABLE `sessions`
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_SessionType` (`sessionType`) REFERENCES `sessiontypes` (`id`);
|
||||||
|
|
||||||
|
ALTER TABLE `triggerevents`
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_SrcUserId` (`srcUserId`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_Type` (`type`) REFERENCES `triggereventtypes` (`id`),
|
||||||
|
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_UserId` (`sessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- VUE sessiondebriefs (CREATE OR REPLACE = idempotent par définition)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE
|
||||||
|
ALGORITHM=UNDEFINED
|
||||||
|
SQL SECURITY DEFINER
|
||||||
|
VIEW `sessiondebriefs` AS
|
||||||
|
SELECT
|
||||||
|
`s`.`id` AS `SessionId`,
|
||||||
|
`s`.`sessionType` AS `SessionTypeId`,
|
||||||
|
`st`.`displayName` AS `SessionType`,
|
||||||
|
`s`.`sessionName` AS `SessionName`,
|
||||||
|
`s`.`sessionDate` AS `SessionDate`,
|
||||||
|
`s`.`mapName` AS `MapName`,
|
||||||
|
`s`.`scenarioName` AS `ScenarioName`,
|
||||||
|
`s`.`success` AS `SessionSuccessful`,
|
||||||
|
`s`.`timeToFinish` AS `SessionDuration`,
|
||||||
|
coalesce(`t`.`type`, -1) AS `TriggerTypeId`,
|
||||||
|
coalesce(`tt`.`displayName`, '') AS `TriggerType`,
|
||||||
|
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)) AS `ShooterId`,
|
||||||
|
`us`.`username` AS `ShooterName`,
|
||||||
|
(SELECT ifnull(`ps`.`role`, 3)) AS `ShooterRoleId`,
|
||||||
|
(SELECT `r`.`displayName` FROM `userroles` `r` WHERE `r`.`id` = `ShooterRoleId`) AS `ShooterRole`,
|
||||||
|
coalesce(`t`.`indexCount`, -1) AS `ShotIndex`,
|
||||||
|
coalesce(`r`.`id`, -1) AS `ReactId`,
|
||||||
|
coalesce(`r`.`reactMode`, 2) AS `ReactModeId`,
|
||||||
|
coalesce(`rm`.`displayName`, '') AS `ReactMode`,
|
||||||
|
coalesce(`r`.`reactType`, -1) AS `ReactTypeId`,
|
||||||
|
coalesce(`rt`.`displayName`, '') AS `ReactType`,
|
||||||
|
coalesce(`r`.`hitUserId`, -1) AS `TargetUserId`,
|
||||||
|
coalesce(`uh`.`username`, '') AS `TargetUserName`,
|
||||||
|
(SELECT ifnull(`ph`.`role`, 3)) AS `TargetRoleId`,
|
||||||
|
(SELECT `r`.`displayName` FROM `userroles` `r` WHERE `r`.`id` = `TargetRoleId`) AS `TargetRole`,
|
||||||
|
coalesce(`r`.`hitTargetName`, '') AS `TargetName`,
|
||||||
|
coalesce(`r`.`hitBoneName`, '') AS `TargetBoneName`,
|
||||||
|
coalesce(`r`.`targetKilled`, 0) AS `TargetKilled`,
|
||||||
|
coalesce(`r`.`objectHitLocationX`, 0) AS `HitLocationX`,
|
||||||
|
coalesce(`r`.`objectHitLocationY`, 0) AS `HitLocationY`,
|
||||||
|
coalesce(`r`.`objectHitTagLocation`, '') AS `HitLocationTag`,
|
||||||
|
coalesce(`r`.`hitPrecision`, 0) AS `HitPrecision`,
|
||||||
|
coalesce(`r`.`distance`, 0) AS `HitTargetDistance`,
|
||||||
|
coalesce(`r`.`reactTime`, 0) AS `ReactionTime`,
|
||||||
|
coalesce(`r`.`timeStamp`, 0) AS `TimeStamp`,
|
||||||
|
count(distinct `r`.`id`) AS `NbHit`,
|
||||||
|
count(distinct `rk`.`srcEventIndex`, `rk`.`hitTargetName`) AS `NbKilled`
|
||||||
|
FROM `sessions` `s`
|
||||||
|
LEFT JOIN `participates` `ps` ON `s`.`id` = `ps`.`sessionId`
|
||||||
|
LEFT JOIN `participates` `ph` ON `s`.`id` = `ph`.`sessionId`
|
||||||
|
LEFT JOIN `triggerevents` `t` ON `s`.`id` = `t`.`sessionId`
|
||||||
|
LEFT JOIN `triggereventtypes` `tt` ON `tt`.`id` = `t`.`type`
|
||||||
|
LEFT JOIN `sessiontypes` `st` ON `st`.`id` = `s`.`sessionType`
|
||||||
|
LEFT JOIN `reactevents` `r` ON `t`.`indexCount` = `r`.`srcEventIndex`
|
||||||
|
AND `t`.`sessionId` = `r`.`srcEventSessionId`
|
||||||
|
LEFT JOIN `users` `uh` ON `uh`.`id` = `r`.`hitUserId`
|
||||||
|
LEFT JOIN `users` `us` ON `us`.`id` = `t`.`srcUserId`
|
||||||
|
OR `us`.`id` = `ps`.`userId`
|
||||||
|
LEFT JOIN `reacteventtypes` `rt` ON `rt`.`id` = `r`.`reactType`
|
||||||
|
LEFT JOIN `reacteventmodes` `rm` ON `rm`.`id` = coalesce(`r`.`reactMode`, 2)
|
||||||
|
LEFT JOIN `reactevents` `rk` ON `r`.`id` = `rk`.`id` AND `rk`.`targetKilled` = 1
|
||||||
|
GROUP BY
|
||||||
|
`s`.`id`,
|
||||||
|
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)),
|
||||||
|
`t`.`indexCount`,
|
||||||
|
`r`.`hitTargetName`
|
||||||
|
ORDER BY
|
||||||
|
`s`.`id` ASC,
|
||||||
|
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)) ASC,
|
||||||
|
`t`.`indexCount` ASC,
|
||||||
|
`r`.`id` ASC;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- v1.4.5 : ajoute le SessionType id=7 "LongRange" et renomme id=6 en "Rescue".
|
||||||
|
-- Idempotent :
|
||||||
|
-- - INSERT IGNORE skip si la PK id=7 existe déjà (cas où 0001_Init.sql récent
|
||||||
|
-- contient déjà LongRange dans son INSERT initial)
|
||||||
|
-- - UPDATE ne génère pas d'erreur si aucune ligne ne match (no-op silencieux)
|
||||||
|
USE proserveapi;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `sessiontypes` (`id`, `displayName`, `description`)
|
||||||
|
VALUES (7, 'LongRange', '');
|
||||||
|
|
||||||
|
UPDATE `sessiontypes` SET `displayName` = 'Rescue' WHERE `id` = 6;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- v1.5.0 : ajoute la colonne `size` à la table `users` (taille en cm).
|
||||||
|
-- Idempotent : `IF NOT EXISTS` skip silencieusement si la colonne existe déjà
|
||||||
|
-- (cas d'une install fresh où 0001_Init.sql l'a déjà créée).
|
||||||
|
USE proserveapi;
|
||||||
|
|
||||||
|
ALTER TABLE `users`
|
||||||
|
ADD COLUMN IF NOT EXISTS `size` INT NOT NULL DEFAULT 175;
|
||||||
Reference in New Issue
Block a user