Compare commits
122 Commits
55ce5318ae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d21c1f24b7 | |||
| e673b1954c | |||
| f8af338faf | |||
| d845edb90f | |||
| dc72e7eee6 | |||
| 53c9e0519c | |||
| 28c5ca5877 | |||
| f9820620dd | |||
| c20d4603a2 | |||
| d52e29151e | |||
| b0ca082b52 | |||
| aae339287b | |||
| 46d7c461c5 | |||
| ff05edbe7e | |||
| 8d8cf32e12 | |||
| e2d9171df1 | |||
| 5b489dcfc5 | |||
| a6ea9b11af | |||
| b3844bfe16 | |||
| 43a6070a65 | |||
| 73d084a703 | |||
| 500f7d12e6 | |||
| 50da755a92 | |||
| 01793eb32d | |||
| eae0514058 | |||
| efb53f079e | |||
| 9b17dfa84c | |||
| aab2e41152 | |||
| d9c3f09e9a | |||
| 67678fe173 | |||
| 38194c6f0c | |||
| 72f99e0c56 | |||
| 49a3b855af | |||
| 0c69bb1c85 | |||
| 7638a7a25c | |||
| 7ff5e56102 | |||
| 95c70903e7 | |||
| 2b95472393 | |||
| 9499c6ae58 | |||
| c371a79f93 | |||
| 3d691c3e38 | |||
| 65ff8541b7 | |||
| 7de0c01b97 | |||
| 9de8e95910 | |||
| be7ccfced5 | |||
| bebb701d70 | |||
| 37a4cf424e | |||
| 7cb2b01403 | |||
| 07ead6011f | |||
| b94c0235b6 | |||
| c997859750 | |||
| 4d0e287227 | |||
| 211af9dd85 | |||
| 741e30b7ae | |||
| 93f54d094a | |||
| d61c1d85fa | |||
| 60358a6cf5 | |||
| 30eceaea2c | |||
| 3c8438f3fe | |||
| 0bd4c8a4be | |||
| 8366ca2669 | |||
| e608a5d29d | |||
| 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 | |||
| 7fb02e4945 | |||
| 07d58a8854 | |||
| 9cea07d6be | |||
| b4f205cbe5 | |||
| 962f5a8ce0 | |||
| 0d4f126e3a | |||
| b9ce591d22 | |||
| 5d0284f1db | |||
| 01a11d5616 | |||
| 9c1b34b041 | |||
| ddfebb8611 | |||
| 2e320d2ab5 | |||
| 7a12153343 | |||
| 8988d130ac | |||
| f5f1fe4d36 | |||
| 2dc9c98d1e | |||
| 89f039e355 | |||
| 661c464b7a | |||
| c27066ffe4 | |||
| 3c31d1ea8a | |||
| 8ebde15535 | |||
| dfad967eae | |||
| 3484c23683 | |||
| e7d4b7a04c | |||
| 9cb0502338 | |||
| 466755ddc2 | |||
| 6da628d687 | |||
| 03483d02e1 | |||
| c24e67f560 | |||
| 5411f607b6 | |||
| fed5be5916 | |||
| f2a1de9aac | |||
| 56069f606d |
36
.gitignore
vendored
36
.gitignore
vendored
@@ -28,14 +28,44 @@ 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
|
||||||
|
# garde le code (.htaccess, gate.php, .gitkeep) sous version.
|
||||||
server/builds/*
|
server/builds/*
|
||||||
!server/builds/.gitkeep
|
!server/builds/.gitkeep
|
||||||
|
!server/builds/.htaccess
|
||||||
|
!server/builds/gate.php
|
||||||
|
!server/builds/launcher/.gitkeep
|
||||||
|
|
||||||
# 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).
|
||||||
|
/PS_Launcher*.exe
|
||||||
|
/PS_Launcher*.bak
|
||||||
|
|
||||||
|
# Sortie de l'installeur Inno Setup
|
||||||
|
/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 ~$
|
||||||
|
~$*
|
||||||
|
|||||||
36
build-all.bat
Normal file
36
build-all.bat
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
@echo off
|
||||||
|
REM Build complet : launcher + updater (sans creer l'installeur).
|
||||||
|
REM Pour l'installeur Inno Setup, utiliser build-installer.bat.
|
||||||
|
|
||||||
|
setlocal
|
||||||
|
set "REPO=%~dp0"
|
||||||
|
echo.
|
||||||
|
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
|
||||||
|
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
|
||||||
|
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
|
||||||
|
echo.
|
||||||
|
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
||||||
|
echo.
|
||||||
|
dotnet publish "%REPO%src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
|
||||||
|
if %ERRORLEVEL% NEQ 0 goto :fail
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ==^> dotnet publish PSLauncher.Updater (Release single-file)
|
||||||
|
echo.
|
||||||
|
dotnet publish "%REPO%src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo
|
||||||
|
if %ERRORLEVEL% NEQ 0 goto :fail
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [OK] Les deux exes sont disponibles a la racine du repo :
|
||||||
|
echo %REPO%PS_Launcher.exe
|
||||||
|
echo %REPO%PS_Launcher.Updater.exe
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
endlocal & exit /b 0
|
||||||
|
|
||||||
|
:fail
|
||||||
|
echo.
|
||||||
|
echo [ECHEC] Build echoue avec le code %ERRORLEVEL%.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
endlocal & exit /b %ERRORLEVEL%
|
||||||
@@ -12,6 +12,10 @@ REM powershell -ExecutionPolicy Bypass -File installer\build-installer.ps1 -IS
|
|||||||
setlocal
|
setlocal
|
||||||
set "SCRIPT_DIR=%~dp0"
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
|
||||||
|
REM Termine les eventuelles instances en cours qui verrouilleraient les .exe
|
||||||
|
taskkill /F /IM PS_Launcher.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%"
|
||||||
|
|
||||||
|
|||||||
27
build-launcher.bat
Normal file
27
build-launcher.bat
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
@echo off
|
||||||
|
REM Build du launcher principal (PS_Launcher.exe).
|
||||||
|
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PS_Launcher.exe (~78 Mo single-file self-contained)
|
||||||
|
|
||||||
|
setlocal
|
||||||
|
set "REPO=%~dp0"
|
||||||
|
echo.
|
||||||
|
echo ==^> Termine les eventuelles instances de PS_Launcher qui verrouilleraient les .exe
|
||||||
|
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
|
||||||
|
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
|
||||||
|
echo.
|
||||||
|
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
dotnet publish "%REPO%src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
|
||||||
|
set "EXITCODE=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
if %EXITCODE% NEQ 0 (
|
||||||
|
echo [ECHEC] Build du launcher echoue avec le code %EXITCODE%.
|
||||||
|
) else (
|
||||||
|
echo [OK] PS_Launcher.exe disponible a la racine du repo :
|
||||||
|
echo %REPO%PS_Launcher.exe
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
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
|
||||||
28
build-updater.bat
Normal file
28
build-updater.bat
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
@echo off
|
||||||
|
REM Build de l'updater (PS_Launcher.Updater.exe).
|
||||||
|
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PS_Launcher.Updater.exe (~34 Mo single-file)
|
||||||
|
REM L'updater accompagne PS_Launcher.exe — il doit etre dans le meme dossier.
|
||||||
|
|
||||||
|
setlocal
|
||||||
|
set "REPO=%~dp0"
|
||||||
|
echo.
|
||||||
|
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
|
||||||
|
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
|
||||||
|
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
|
||||||
|
echo.
|
||||||
|
echo ==^> dotnet publish PSLauncher.Updater (Release single-file)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
dotnet publish "%REPO%src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo
|
||||||
|
set "EXITCODE=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
if %EXITCODE% NEQ 0 (
|
||||||
|
echo [ECHEC] Build de l'updater echoue avec le code %EXITCODE%.
|
||||||
|
) else (
|
||||||
|
echo [OK] PS_Launcher.Updater.exe disponible a la racine du repo :
|
||||||
|
echo %REPO%PS_Launcher.Updater.exe
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
endlocal ^& exit /b %EXITCODE%
|
||||||
BIN
docs/PS_Launcher-Documentation-Interne.pptx
Normal file
BIN
docs/PS_Launcher-Documentation-Interne.pptx
Normal file
Binary file not shown.
BIN
docs/PS_Launcher-Guide-Utilisateur.pptx
Normal file
BIN
docs/PS_Launcher-Guide-Utilisateur.pptx
Normal file
Binary file not shown.
26
docs/README.md
Normal file
26
docs/README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Documentation PS_Launcher
|
||||||
|
|
||||||
|
Deux documents PowerPoint, l'un pour l'admin/dev (interne), l'autre destiné aux clients finaux.
|
||||||
|
|
||||||
|
| Fichier | Public | Contenu |
|
||||||
|
|---|---|---|
|
||||||
|
| [PS_Launcher-Documentation-Interne.pptx](PS_Launcher-Documentation-Interne.pptx) | Équipe ASTERION | Architecture, setup OVH, backoffice, workflows release, sécurité, troubleshooting |
|
||||||
|
| [PS_Launcher-Guide-Utilisateur.pptx](PS_Launcher-Guide-Utilisateur.pptx) | Clients | Installation, activation license, utilisation, FAQ, support |
|
||||||
|
|
||||||
|
## Régénérer les documents
|
||||||
|
|
||||||
|
Si tu modifies le contenu, regénère les `.pptx` depuis les sources Node :
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd docs
|
||||||
|
$env:NODE_PATH = "$env:APPDATA\npm\node_modules"
|
||||||
|
node _build_internal.js
|
||||||
|
node _build_user.js
|
||||||
|
```
|
||||||
|
|
||||||
|
(Pré-requis : Node.js + `npm install -g pptxgenjs`)
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
|
||||||
|
- **Doc interne** : reste dans le repo, pas distribuée. Garde-la à jour pour les nouveaux arrivants côté équipe.
|
||||||
|
- **Guide utilisateur** : embarqué automatiquement par l'installeur Inno Setup dans `{app}\docs\`. Un raccourci « Guide utilisateur » est créé dans le menu Démarrer à l'install.
|
||||||
981
docs/_build_internal.js
Normal file
981
docs/_build_internal.js
Normal file
@@ -0,0 +1,981 @@
|
|||||||
|
// Generates PS_Launcher-Documentation-Interne.pptx
|
||||||
|
const pptxgen = require("pptxgenjs");
|
||||||
|
|
||||||
|
const COL = {
|
||||||
|
bg: "000000",
|
||||||
|
card: "161B23",
|
||||||
|
elev: "1E2632",
|
||||||
|
border: "2A2F3A",
|
||||||
|
text: "F2F2F2",
|
||||||
|
muted: "A0A0A8",
|
||||||
|
accent: "3B82F6",
|
||||||
|
accentDk: "1F3A66",
|
||||||
|
green: "16A34A",
|
||||||
|
greenDk: "0E2A1B",
|
||||||
|
amber: "F59E0B",
|
||||||
|
amberDk: "3A2A0E",
|
||||||
|
red: "EF4444",
|
||||||
|
redDk: "2A1414",
|
||||||
|
codeBg: "0A0E14",
|
||||||
|
};
|
||||||
|
|
||||||
|
const F_TITLE = "Calibri";
|
||||||
|
const F_BODY = "Calibri";
|
||||||
|
const F_CODE = "Consolas";
|
||||||
|
|
||||||
|
const pres = new pptxgen();
|
||||||
|
pres.layout = "LAYOUT_16x9";
|
||||||
|
pres.author = "ASTERION VR";
|
||||||
|
pres.title = "PS_Launcher — Documentation Interne";
|
||||||
|
pres.company = "ASTERION VR";
|
||||||
|
|
||||||
|
const W = 10, H = 5.625;
|
||||||
|
|
||||||
|
// ===================== HELPERS =====================
|
||||||
|
function fillBg(slide) {
|
||||||
|
slide.background = { color: COL.bg };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addHeader(slide, title, kicker) {
|
||||||
|
// Top accent bar
|
||||||
|
slide.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0, y: 0, w: W, h: 0.06, fill: { color: COL.accent }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
if (kicker) {
|
||||||
|
slide.addText(kicker.toUpperCase(), {
|
||||||
|
x: 0.5, y: 0.25, w: W - 1, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 11, bold: true,
|
||||||
|
color: COL.accent, charSpacing: 4, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
slide.addText(title, {
|
||||||
|
x: 0.5, y: kicker ? 0.55 : 0.35, w: W - 1, h: 0.7,
|
||||||
|
fontFace: F_TITLE, fontSize: 32, bold: true,
|
||||||
|
color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
// Underline-ish thin separator
|
||||||
|
slide.addShape(pres.shapes.LINE, {
|
||||||
|
x: 0.5, y: kicker ? 1.3 : 1.15, w: 1.2, h: 0,
|
||||||
|
line: { color: COL.accent, width: 2 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFooter(slide, page) {
|
||||||
|
slide.addText("PS_LAUNCHER • Documentation Interne • ASTERION VR", {
|
||||||
|
x: 0.5, y: H - 0.35, w: W - 2, h: 0.25,
|
||||||
|
fontFace: F_BODY, fontSize: 9, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
if (page) {
|
||||||
|
slide.addText(String(page), {
|
||||||
|
x: W - 1, y: H - 0.35, w: 0.5, h: 0.25,
|
||||||
|
fontFace: F_BODY, fontSize: 9, color: COL.muted, align: "right", margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCard(slide, x, y, w, h, options = {}) {
|
||||||
|
slide.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x, y, w, h,
|
||||||
|
fill: { color: options.fill || COL.card },
|
||||||
|
line: { color: options.border || COL.border, width: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAccentCard(slide, x, y, w, h, accentColor) {
|
||||||
|
addCard(slide, x, y, w, h);
|
||||||
|
// left accent strip
|
||||||
|
slide.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x, y, w: 0.08, h,
|
||||||
|
fill: { color: accentColor }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCodeBlock(slide, x, y, w, h, lines) {
|
||||||
|
slide.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x, y, w, h, fill: { color: COL.codeBg }, line: { color: COL.border, width: 1 }
|
||||||
|
});
|
||||||
|
const text = (Array.isArray(lines) ? lines : [lines]).map((line, i, arr) => ({
|
||||||
|
text: line,
|
||||||
|
options: { breakLine: i < arr.length - 1 }
|
||||||
|
}));
|
||||||
|
slide.addText(text, {
|
||||||
|
x: x + 0.15, y: y + 0.1, w: w - 0.3, h: h - 0.2,
|
||||||
|
fontFace: F_CODE, fontSize: 11, color: COL.text, margin: 0,
|
||||||
|
valign: "top"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBullets(slide, x, y, w, h, items, opts = {}) {
|
||||||
|
const fontSize = opts.fontSize || 14;
|
||||||
|
const color = opts.color || COL.text;
|
||||||
|
const arr = items.map((it, i) => ({
|
||||||
|
text: it,
|
||||||
|
options: { bullet: { code: "25A0" }, breakLine: i < items.length - 1, color }
|
||||||
|
}));
|
||||||
|
slide.addText(arr, {
|
||||||
|
x, y, w, h, fontFace: F_BODY, fontSize, color, margin: 0,
|
||||||
|
paraSpaceAfter: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPill(slide, x, y, w, h, label, color) {
|
||||||
|
slide.addShape(pres.shapes.ROUNDED_RECTANGLE, {
|
||||||
|
x, y, w, h, fill: { color }, line: { type: "none" },
|
||||||
|
rectRadius: h / 2
|
||||||
|
});
|
||||||
|
slide.addText(label, {
|
||||||
|
x, y, w, h, fontFace: F_BODY, fontSize: 10, bold: true,
|
||||||
|
color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 1: TITLE =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
|
||||||
|
// Big colored band on the right
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 6.5, y: 0, w: 3.5, h: H, fill: { color: COL.accentDk }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vertical accent line
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 6.5, y: 0, w: 0.05, h: H, fill: { color: COL.accent }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Brand label
|
||||||
|
s.addText("ASTERION VR", {
|
||||||
|
x: 0.6, y: 0.5, w: 4, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 13, bold: true,
|
||||||
|
color: COL.accent, charSpacing: 8, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
s.addText("PS_Launcher", {
|
||||||
|
x: 0.6, y: 1.5, w: 6, h: 1,
|
||||||
|
fontFace: F_TITLE, fontSize: 56, bold: true,
|
||||||
|
color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
s.addText("Documentation Interne", {
|
||||||
|
x: 0.6, y: 2.5, w: 6, h: 0.7,
|
||||||
|
fontFace: F_TITLE, fontSize: 28,
|
||||||
|
color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
s.addText("Déploiement, configuration & exploitation", {
|
||||||
|
x: 0.6, y: 3.2, w: 6, h: 0.5,
|
||||||
|
fontFace: F_BODY, fontSize: 16, italic: true,
|
||||||
|
color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Right column big version pill
|
||||||
|
s.addText("v0.8", {
|
||||||
|
x: 6.7, y: 1.5, w: 3.2, h: 1.2,
|
||||||
|
fontFace: F_TITLE, fontSize: 76, bold: true,
|
||||||
|
color: COL.text, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
s.addText("VERSION", {
|
||||||
|
x: 6.7, y: 2.6, w: 3.2, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 11,
|
||||||
|
color: COL.muted, align: "center", charSpacing: 6, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Date
|
||||||
|
s.addText("Mai 2026", {
|
||||||
|
x: 6.7, y: 3.6, w: 3.2, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 14,
|
||||||
|
color: COL.text, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bottom band
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0, y: H - 0.5, w: W, h: 0.5, fill: { color: COL.card }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addText("Confidentiel — usage interne uniquement", {
|
||||||
|
x: 0.5, y: H - 0.45, w: W - 1, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, italic: true,
|
||||||
|
color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 2: SOMMAIRE =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Sommaire");
|
||||||
|
addFooter(s, 2);
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
["1", "Architecture", "Vue d'ensemble client-serveur"],
|
||||||
|
["2", "Setup OVH initial", "MySQL, config.php, clés Ed25519"],
|
||||||
|
["3", "Tour du backoffice", "Dashboard, Licenses, Versions, Launcher, Audit"],
|
||||||
|
["4", "Workflow de release", "Proserve & launcher"],
|
||||||
|
["5", "Gestion des licenses", "Émission, prolongation, révocation"],
|
||||||
|
["6", "Sécurité", "URLs HMAC, signatures Ed25519, DPAPI"],
|
||||||
|
["7", "Exploitation", "Logs, fichiers, troubleshooting"],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.6;
|
||||||
|
sections.forEach(([num, title, desc]) => {
|
||||||
|
// Number circle
|
||||||
|
s.addShape(pres.shapes.OVAL, {
|
||||||
|
x: 0.5, y, w: 0.5, h: 0.5,
|
||||||
|
fill: { color: COL.accent }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addText(num, {
|
||||||
|
x: 0.5, y, w: 0.5, h: 0.5,
|
||||||
|
fontFace: F_TITLE, fontSize: 18, bold: true,
|
||||||
|
color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
// Title
|
||||||
|
s.addText(title, {
|
||||||
|
x: 1.2, y: y - 0.05, w: 4, h: 0.35,
|
||||||
|
fontFace: F_TITLE, fontSize: 16, bold: true,
|
||||||
|
color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
// Description
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 1.2, y: y + 0.27, w: 6, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 12,
|
||||||
|
color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.55;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 3: ARCHITECTURE =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Architecture", "section 1");
|
||||||
|
addFooter(s, 3);
|
||||||
|
|
||||||
|
// Three boxes : Client - HTTPS - OVH
|
||||||
|
const boxY = 1.8, boxH = 2.2, boxW = 2.7;
|
||||||
|
|
||||||
|
// Client
|
||||||
|
addAccentCard(s, 0.5, boxY, boxW, boxH, COL.green);
|
||||||
|
s.addText("CLIENT", {
|
||||||
|
x: 0.7, y: boxY + 0.1, w: boxW - 0.3, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 10, bold: true, color: COL.muted, charSpacing: 4, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("PSLauncher.exe", {
|
||||||
|
x: 0.7, y: boxY + 0.4, w: boxW - 0.3, h: 0.4,
|
||||||
|
fontFace: F_TITLE, fontSize: 18, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
addBullets(s, 0.7, boxY + 0.85, boxW - 0.3, 1.3, [
|
||||||
|
"WPF / .NET 8",
|
||||||
|
"License DPAPI locale",
|
||||||
|
"Cache & logs",
|
||||||
|
"Updater intégré"
|
||||||
|
], { fontSize: 11, color: COL.muted });
|
||||||
|
|
||||||
|
// Arrow / HTTPS
|
||||||
|
s.addShape(pres.shapes.LINE, {
|
||||||
|
x: 3.3, y: boxY + 1.1, w: 0.8, h: 0,
|
||||||
|
line: { color: COL.accent, width: 2.5 }
|
||||||
|
});
|
||||||
|
s.addText("HTTPS", {
|
||||||
|
x: 3.3, y: boxY + 0.7, w: 0.8, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 11, bold: true, color: COL.accent, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// OVH
|
||||||
|
addAccentCard(s, 4.15, boxY, boxW, boxH, COL.accent);
|
||||||
|
s.addText("SERVEUR", {
|
||||||
|
x: 4.35, y: boxY + 0.1, w: boxW - 0.3, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 10, bold: true, color: COL.muted, charSpacing: 4, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("OVH mutualisé", {
|
||||||
|
x: 4.35, y: boxY + 0.4, w: boxW - 0.3, h: 0.4,
|
||||||
|
fontFace: F_TITLE, fontSize: 18, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
addBullets(s, 4.35, boxY + 0.85, boxW - 0.3, 1.3, [
|
||||||
|
"PHP 8.3 + Apache",
|
||||||
|
"API REST + backoffice",
|
||||||
|
"Manifest signé Ed25519",
|
||||||
|
"Builds ZIPs servis HMAC"
|
||||||
|
], { fontSize: 11, color: COL.muted });
|
||||||
|
|
||||||
|
// Arrow / SQL
|
||||||
|
s.addShape(pres.shapes.LINE, {
|
||||||
|
x: 6.95, y: boxY + 1.1, w: 0.8, h: 0,
|
||||||
|
line: { color: COL.accent, width: 2.5 }
|
||||||
|
});
|
||||||
|
s.addText("PDO", {
|
||||||
|
x: 6.95, y: boxY + 0.7, w: 0.8, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 11, bold: true, color: COL.accent, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// MySQL
|
||||||
|
addAccentCard(s, 7.8, boxY, boxW - 0.5, boxH, COL.amber);
|
||||||
|
s.addText("BASE", {
|
||||||
|
x: 8, y: boxY + 0.1, w: boxW - 0.7, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 10, bold: true, color: COL.muted, charSpacing: 4, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("MySQL OVH", {
|
||||||
|
x: 8, y: boxY + 0.4, w: boxW - 0.7, h: 0.4,
|
||||||
|
fontFace: F_TITLE, fontSize: 18, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
addBullets(s, 8, boxY + 0.85, boxW - 0.7, 1.3, [
|
||||||
|
"licenses",
|
||||||
|
"license_machines",
|
||||||
|
"rate_limit",
|
||||||
|
"audit_log"
|
||||||
|
], { fontSize: 11, color: COL.muted });
|
||||||
|
|
||||||
|
// Footnote
|
||||||
|
s.addText("Le launcher conserve sa license en cache local (DPAPI scope CurrentUser) et fonctionne offline 7 jours après la dernière validation serveur.", {
|
||||||
|
x: 0.5, y: 4.4, w: W - 1, h: 0.6,
|
||||||
|
fontFace: F_BODY, fontSize: 11, italic: true, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 4: SETUP OVH =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Setup OVH initial", "section 2 — première fois uniquement");
|
||||||
|
addFooter(s, 4);
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
["1", "Créer la base MySQL", "Manager OVH → Bases de données → Créer.\nNoter host, user, password, nom."],
|
||||||
|
["2", "Jouer la migration", "PhpMyAdmin OVH → onglet SQL → coller migrations/001_init.sql\n(crée 4 tables : licenses, license_machines, rate_limit, audit_log)"],
|
||||||
|
["3", "Uploader le code en SFTP", "server/ → www/PS_Launcher/\nRespecter l'arborescence : api/, admin/, manifest/, builds/, tools/, migrations/"],
|
||||||
|
["4", "Créer api/config.php", "Copier api/config.example.php → api/config.php\nÉditer DSN MySQL, secrets HMAC/JWT, hash bcrypt admin, paire Ed25519"],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.5;
|
||||||
|
steps.forEach(([num, title, desc]) => {
|
||||||
|
addCard(s, 0.5, y, W - 1, 0.85);
|
||||||
|
// Number
|
||||||
|
s.addShape(pres.shapes.OVAL, {
|
||||||
|
x: 0.7, y: y + 0.2, w: 0.45, h: 0.45,
|
||||||
|
fill: { color: COL.accent }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addText(num, {
|
||||||
|
x: 0.7, y: y + 0.2, w: 0.45, h: 0.45,
|
||||||
|
fontFace: F_TITLE, fontSize: 16, bold: true,
|
||||||
|
color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
// Title
|
||||||
|
s.addText(title, {
|
||||||
|
x: 1.4, y: y + 0.1, w: 7.5, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
// Desc
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 1.4, y: y + 0.4, w: 7.5, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.95;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 5: CONFIG.PHP =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "config.php — secrets serveur", "section 2");
|
||||||
|
addFooter(s, 5);
|
||||||
|
|
||||||
|
addCodeBlock(s, 0.5, 1.5, W - 1, 2.6, [
|
||||||
|
"<?php return [",
|
||||||
|
" 'base_url' => 'https://asterionvr.com/PS_Launcher',",
|
||||||
|
" 'db' => [",
|
||||||
|
" 'dsn' => 'mysql:host=xxx.mysql.db;dbname=xxx;charset=utf8mb4',",
|
||||||
|
" 'username' => '...', 'password' => '...',",
|
||||||
|
" ],",
|
||||||
|
" 'hmac_secret' => '<64 hex chars>', // bin2hex(random_bytes(32))",
|
||||||
|
" 'ed25519' => [",
|
||||||
|
" 'private_key_hex' => '<128 hex>', // tools/generate-keypair.php",
|
||||||
|
" 'public_key_hex' => '<64 hex>', // → idem + Resources/server-pubkey.txt",
|
||||||
|
" ],",
|
||||||
|
" 'jwt_secret' => '<64 hex>',",
|
||||||
|
" 'admin_password_hash' => '$2y$10$...' // password_hash('...')",
|
||||||
|
"];",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Tip card
|
||||||
|
addAccentCard(s, 0.5, 4.3, W - 1, 0.85, COL.amber);
|
||||||
|
s.addText("⚠ Point critique", {
|
||||||
|
x: 0.7, y: 4.4, w: 3, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.amber, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("public_key_hex doit être recopié à l'identique dans Resources/server-pubkey.txt côté projet C# avant un nouveau publish — sinon le launcher refusera la signature serveur.", {
|
||||||
|
x: 0.7, y: 4.7, w: W - 1.4, h: 0.5,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 6: GENERATE KEYS =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Génération des secrets", "section 2");
|
||||||
|
addFooter(s, 6);
|
||||||
|
|
||||||
|
// Two columns
|
||||||
|
const c1 = 0.5, c2 = 5.2, cw = 4.3;
|
||||||
|
|
||||||
|
// Col 1 : Ed25519
|
||||||
|
s.addText("🔐 Keypair Ed25519", {
|
||||||
|
x: c1, y: 1.4, w: cw, h: 0.4,
|
||||||
|
fontFace: F_TITLE, fontSize: 15, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Signe le manifest et la réponse de validation license.", {
|
||||||
|
x: c1, y: 1.7, w: cw, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
addCodeBlock(s, c1, 2.1, cw, 0.7, [
|
||||||
|
"$ cd ~/www/PS_Launcher",
|
||||||
|
"$ php tools/generate-keypair.php",
|
||||||
|
]);
|
||||||
|
addBullets(s, c1, 2.95, cw, 1.6, [
|
||||||
|
"private_key_hex → api/config.php uniquement",
|
||||||
|
"public_key_hex → api/config.php ET",
|
||||||
|
" Resources/server-pubkey.txt",
|
||||||
|
], { fontSize: 11 });
|
||||||
|
|
||||||
|
// Col 2 : Admin password
|
||||||
|
s.addText("🔑 Mot de passe admin", {
|
||||||
|
x: c2, y: 1.4, w: cw, h: 0.4,
|
||||||
|
fontFace: F_TITLE, fontSize: 15, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Bcrypt — pour la connexion à /admin/.", {
|
||||||
|
x: c2, y: 1.7, w: cw, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
addCodeBlock(s, c2, 2.1, cw, 1.3, [
|
||||||
|
"$ php -r 'echo password_hash(",
|
||||||
|
" \"<motdepasse>\",",
|
||||||
|
" PASSWORD_DEFAULT) . \"\\n\";'",
|
||||||
|
]);
|
||||||
|
addBullets(s, c2, 3.55, cw, 1.0, [
|
||||||
|
"Hash dans api/config.php",
|
||||||
|
" → admin_password_hash",
|
||||||
|
], { fontSize: 11 });
|
||||||
|
|
||||||
|
// hmac/jwt
|
||||||
|
addCard(s, 0.5, 4.7, W - 1, 0.65);
|
||||||
|
s.addText("🎲 HMAC & JWT secrets", {
|
||||||
|
x: 0.7, y: 4.8, w: 4, h: 0.25,
|
||||||
|
fontFace: F_TITLE, fontSize: 12, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Générer 64 hex chars : php -r \"echo bin2hex(random_bytes(32));\"", {
|
||||||
|
x: 0.7, y: 5.0, w: W - 1.4, h: 0.3,
|
||||||
|
fontFace: F_CODE, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 7: BACKOFFICE - DASHBOARD =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Backoffice — Dashboard", "section 3 · vue d'ensemble");
|
||||||
|
addFooter(s, 7);
|
||||||
|
|
||||||
|
s.addText("URL : https://asterionvr.com/PS_Launcher/admin/", {
|
||||||
|
x: 0.5, y: 1.4, w: W - 1, h: 0.3,
|
||||||
|
fontFace: F_CODE, fontSize: 12, color: COL.accent, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// KPI grid 5 cells
|
||||||
|
const kpiW = 1.85, kpiH = 1.0, kpiY = 1.95, gap = 0.05;
|
||||||
|
const kpis = [
|
||||||
|
["Licenses actives", "•", COL.green],
|
||||||
|
["Expirées", "•", COL.amber],
|
||||||
|
["Révoquées", "•", COL.red],
|
||||||
|
["Machines vues 30j", "•", COL.text],
|
||||||
|
["Validations 24h", "•", COL.text],
|
||||||
|
];
|
||||||
|
kpis.forEach((kpi, i) => {
|
||||||
|
const [label, val, color] = kpi;
|
||||||
|
const x = 0.5 + i * (kpiW + gap);
|
||||||
|
addCard(s, x, kpiY, kpiW, kpiH);
|
||||||
|
s.addText(label.toUpperCase(), {
|
||||||
|
x: x + 0.1, y: kpiY + 0.1, w: kpiW - 0.2, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 9, bold: true, color: COL.muted, charSpacing: 2, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(val, {
|
||||||
|
x: x + 0.1, y: kpiY + 0.35, w: kpiW - 0.2, h: 0.55,
|
||||||
|
fontFace: F_TITLE, fontSize: 32, bold: true, color, margin: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Liste features
|
||||||
|
s.addText("Ce que tu y vois", {
|
||||||
|
x: 0.5, y: 3.2, w: W - 1, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
addBullets(s, 0.5, 3.5, W - 1, 1.3, [
|
||||||
|
"Compteurs licenses + machines + validations en temps réel",
|
||||||
|
"État du dépôt de versions (manifest signé / non signé, version latest)",
|
||||||
|
"10 dernières lignes de l'audit log avec leur sévérité (validate_ok, expired, revoked, …)",
|
||||||
|
], { fontSize: 12 });
|
||||||
|
|
||||||
|
// Bottom callout
|
||||||
|
addAccentCard(s, 0.5, 4.85, W - 1, 0.5, COL.accent);
|
||||||
|
s.addText("✓ Page d'entrée — pas d'action destructive ici, c'est le tableau de bord lecture seule.", {
|
||||||
|
x: 0.7, y: 4.9, w: W - 1.4, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 8: BACKOFFICE - LICENSES =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Backoffice — Licenses", "section 3 · gestion clients");
|
||||||
|
addFooter(s, 8);
|
||||||
|
|
||||||
|
s.addText("Émettre, prolonger, révoquer, libérer les machines occupées.", {
|
||||||
|
x: 0.5, y: 1.4, w: W - 1, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 13, italic: true, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Workflow boxes
|
||||||
|
const flow = [
|
||||||
|
["Émettre", "Formulaire en haut\nNom client, date d'expiration, max machines, notes\n→ Clé PRSRV-XXXX-XXXX-XXXX-XXXX affichée UNE FOIS", COL.green],
|
||||||
|
["Prolonger", "Bouton Prolonger / ligne\n→ Champ date → la nouvelle date d'expiration prend effet immédiatement côté client à la prochaine validation", COL.accent],
|
||||||
|
["Révoquer", "Bouton rouge / ligne\n→ revoked_at = NOW()\nLe client perd ses droits de DL au prochain check, le launcher reste utilisable sur les versions déjà installées", COL.red],
|
||||||
|
["Libérer machines", "Si un client change de PC, ses anciennes machines occupent un slot\n→ Bouton Libérer machines vide la table license_machines pour cette license", COL.amber],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.85;
|
||||||
|
flow.forEach(([title, desc, color]) => {
|
||||||
|
addAccentCard(s, 0.5, y, W - 1, 0.75, color);
|
||||||
|
s.addText(title, {
|
||||||
|
x: 0.75, y: y + 0.1, w: 2, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 2.7, y: y + 0.05, w: W - 3.2, h: 0.7,
|
||||||
|
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.85;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 9: BACKOFFICE - VERSIONS =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Backoffice — Versions Proserve", "section 3 · catalogue des builds");
|
||||||
|
addFooter(s, 9);
|
||||||
|
|
||||||
|
s.addText("Édite manifest/versions.json depuis le web (sans toucher au JSON à la main).", {
|
||||||
|
x: 0.5, y: 1.4, w: W - 1, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 13, italic: true, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Workflow numbered
|
||||||
|
const steps = [
|
||||||
|
["1", "Ajouter version", "Formulaire haut → Numéro X.Y.Z, date min de license requise, release notes en Markdown."],
|
||||||
|
["2", "Upload SFTP du ZIP", "Dans builds/proserve-X.Y.Z.zip — taille typique 14 Go par build UE5."],
|
||||||
|
["3", "Hasher + signer", "Bouton bleu 🔁 Hasher les versions + signer.\nCalcule sizeBytes + sha256, bump latest, signe Ed25519."],
|
||||||
|
["4", "Édition fine", "Méta (date, minLicenseDate), Notes (Markdown), toggle disponibilité, retirer du manifest."],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.85;
|
||||||
|
steps.forEach(([num, title, desc]) => {
|
||||||
|
addCard(s, 0.5, y, W - 1, 0.75);
|
||||||
|
s.addShape(pres.shapes.OVAL, {
|
||||||
|
x: 0.7, y: y + 0.18, w: 0.4, h: 0.4,
|
||||||
|
fill: { color: COL.accent }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addText(num, {
|
||||||
|
x: 0.7, y: y + 0.18, w: 0.4, h: 0.4,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true,
|
||||||
|
color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
s.addText(title, {
|
||||||
|
x: 1.3, y: y + 0.1, w: 8, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 1.3, y: y + 0.4, w: 8, h: 0.35,
|
||||||
|
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.85;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 10: BACKOFFICE - LAUNCHER =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Backoffice — Launcher (auto-update)", "section 3 · MAJ du launcher lui-même");
|
||||||
|
addFooter(s, 10);
|
||||||
|
|
||||||
|
s.addText("Quand tu pousses une nouvelle version de PSLauncher.exe, les launchers déjà déployés se mettent à jour seuls. Cette page pilote ce flux.", {
|
||||||
|
x: 0.5, y: 1.4, w: W - 1, h: 0.45,
|
||||||
|
fontFace: F_BODY, fontSize: 12, italic: true, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two columns: Status & Workflow
|
||||||
|
const colY = 2.0;
|
||||||
|
|
||||||
|
// Status card
|
||||||
|
addCard(s, 0.5, colY, 4.3, 2.6);
|
||||||
|
s.addText("État affiché en haut", {
|
||||||
|
x: 0.7, y: colY + 0.15, w: 4, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
addBullets(s, 0.7, colY + 0.55, 4, 2, [
|
||||||
|
"Version annoncée (ex : v0.8.0)",
|
||||||
|
"minRequired (plancher de compatibilité)",
|
||||||
|
"Exe présent / absent dans builds/launcher/",
|
||||||
|
"Hash calculé / à calculer",
|
||||||
|
"Manifest signé / non signé",
|
||||||
|
], { fontSize: 11 });
|
||||||
|
|
||||||
|
// Workflow card
|
||||||
|
addCard(s, 5.2, colY, 4.3, 2.6);
|
||||||
|
s.addText("Workflow nouvelle version", {
|
||||||
|
x: 5.4, y: colY + 0.15, w: 4, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
addBullets(s, 5.4, colY + 0.55, 4, 2, [
|
||||||
|
"build-launcher.bat (publie l'exe)",
|
||||||
|
"Renommer en PSLauncher-X.Y.Z.exe",
|
||||||
|
"Upload SFTP dans builds/launcher/",
|
||||||
|
"Définir version dans le formulaire",
|
||||||
|
"Bouton bleu : 🔁 Hasher le launcher + signer",
|
||||||
|
], { fontSize: 11 });
|
||||||
|
|
||||||
|
// Bottom note
|
||||||
|
addAccentCard(s, 0.5, 4.7, W - 1, 0.55, COL.accent);
|
||||||
|
s.addText("ℹ Les clients en Program Files passeront un UAC à la mise à jour ; les nouveaux installs (user-mode) n'en demanderont jamais.", {
|
||||||
|
x: 0.7, y: 4.78, w: W - 1.4, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 11: BACKOFFICE - AUDIT =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Backoffice — Audit log", "section 3 · traçabilité");
|
||||||
|
addFooter(s, 11);
|
||||||
|
|
||||||
|
s.addText("Toutes les validations license + émissions d'URL signées sont enregistrées avec horodatage, IP et détail JSON.", {
|
||||||
|
x: 0.5, y: 1.4, w: W - 1, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 12, italic: true, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event types table
|
||||||
|
addCard(s, 0.5, 1.95, W - 1, 3);
|
||||||
|
s.addText("Événements possibles", {
|
||||||
|
x: 0.7, y: 2.05, w: W - 1.4, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
["validate_ok", "License validée avec succès", COL.green],
|
||||||
|
["validate_expired", "Clé connue mais date dépassée", COL.amber],
|
||||||
|
["validate_revoked", "License révoquée par l'admin", COL.red],
|
||||||
|
["validate_invalid", "Clé inconnue (faute de frappe)", COL.red],
|
||||||
|
["machine_limit", "Slot machines max atteint", COL.red],
|
||||||
|
["download_url_issued", "URL HMAC signée pour DL d'un ZIP", COL.accent],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 2.45;
|
||||||
|
events.forEach(([code, desc, color]) => {
|
||||||
|
// Pill
|
||||||
|
addPill(s, 0.7, y, 2.0, 0.28, code, color);
|
||||||
|
// Description
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 2.85, y: y - 0.04, w: 6, h: 0.35,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.4;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 12: WORKFLOW PROSERVE =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Workflow release — Proserve", "section 4");
|
||||||
|
addFooter(s, 12);
|
||||||
|
|
||||||
|
// Big timeline
|
||||||
|
const stepW = 1.7, stepGap = 0.18, sy = 1.7;
|
||||||
|
const steps = [
|
||||||
|
["1", "Package", "Unreal\n→ ZIP de\nla version"],
|
||||||
|
["2", "SFTP", "Upload dans\nbuilds/\n(14 Go)"],
|
||||||
|
["3", "Backoffice", "Onglet Versions\nAjouter X.Y.Z\nRelease notes"],
|
||||||
|
["4", "Sign", "Bouton bleu\n🔁 Hasher\n+ signer"],
|
||||||
|
["5", "✓", "Manifest signé\nClients voient\nla MAJ"],
|
||||||
|
];
|
||||||
|
steps.forEach((step, i) => {
|
||||||
|
const x = 0.5 + i * (stepW + stepGap);
|
||||||
|
const [num, title, desc] = step;
|
||||||
|
const isLast = i === steps.length - 1;
|
||||||
|
addCard(s, x, sy, stepW, 2.6, {
|
||||||
|
fill: isLast ? COL.greenDk : COL.card,
|
||||||
|
border: isLast ? COL.green : COL.border
|
||||||
|
});
|
||||||
|
s.addShape(pres.shapes.OVAL, {
|
||||||
|
x: x + (stepW - 0.55) / 2, y: sy + 0.2, w: 0.55, h: 0.55,
|
||||||
|
fill: { color: isLast ? COL.green : COL.accent }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addText(num, {
|
||||||
|
x: x + (stepW - 0.55) / 2, y: sy + 0.2, w: 0.55, h: 0.55,
|
||||||
|
fontFace: F_TITLE, fontSize: 18, bold: true,
|
||||||
|
color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
s.addText(title, {
|
||||||
|
x: x + 0.1, y: sy + 0.85, w: stepW - 0.2, h: 0.35,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true,
|
||||||
|
color: COL.text, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: x + 0.1, y: sy + 1.25, w: stepW - 0.2, h: 1.2,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connecting arrow
|
||||||
|
if (i < steps.length - 1) {
|
||||||
|
s.addShape(pres.shapes.LINE, {
|
||||||
|
x: x + stepW, y: sy + 0.475, w: stepGap, h: 0,
|
||||||
|
line: { color: COL.border, width: 1.5 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
addAccentCard(s, 0.5, 4.6, W - 1, 0.65, COL.amber);
|
||||||
|
s.addText("⚠ Tant que le ZIP n'est pas hashé/signé, les clients ne le verront pas — la signature Ed25519 prouve l'intégrité.", {
|
||||||
|
x: 0.7, y: 4.7, w: W - 1.4, h: 0.5,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 13: WORKFLOW LAUNCHER =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Workflow release — Launcher", "section 4");
|
||||||
|
addFooter(s, 13);
|
||||||
|
|
||||||
|
s.addText("Pour pousser une nouvelle version du launcher elle-même (auto-update transparent côté client).", {
|
||||||
|
x: 0.5, y: 1.4, w: W - 1, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 12, italic: true, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two columns: scripts / steps
|
||||||
|
addCard(s, 0.5, 1.85, 4.3, 3.3);
|
||||||
|
s.addText("Scripts disponibles à la racine", {
|
||||||
|
x: 0.7, y: 1.95, w: 4, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
const scripts = [
|
||||||
|
["build-launcher.bat", "Publie PSLauncher.exe (~78 Mo)"],
|
||||||
|
["build-updater.bat", "Publie PSLauncher.Updater.exe"],
|
||||||
|
["build-all.bat", "Les deux d'un coup"],
|
||||||
|
["build-installer.bat","Tout + Inno Setup .exe"],
|
||||||
|
];
|
||||||
|
let y = 2.35;
|
||||||
|
scripts.forEach(([cmd, desc]) => {
|
||||||
|
s.addText(cmd, {
|
||||||
|
x: 0.7, y, w: 4, h: 0.25,
|
||||||
|
fontFace: F_CODE, fontSize: 11, color: COL.accent, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 0.7, y: y + 0.25, w: 4, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.65;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Right: workflow
|
||||||
|
addCard(s, 5.2, 1.85, 4.3, 3.3);
|
||||||
|
s.addText("Étapes d'une release", {
|
||||||
|
x: 5.4, y: 1.95, w: 4, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
const wsteps = [
|
||||||
|
"Bumper <Version> dans PSLauncher.App.csproj",
|
||||||
|
"build-launcher.bat",
|
||||||
|
"Renommer PSLauncher.exe → PSLauncher-X.Y.Z.exe",
|
||||||
|
"SFTP → builds/launcher/",
|
||||||
|
"Backoffice → Launcher → Définir X.Y.Z",
|
||||||
|
"Bouton bleu 🔁 Hasher le launcher + signer",
|
||||||
|
];
|
||||||
|
addBullets(s, 5.4, 2.4, 4, 2.5, wsteps, { fontSize: 11 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 14: SECURITY =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Sécurité", "section 6");
|
||||||
|
addFooter(s, 14);
|
||||||
|
|
||||||
|
const blocks = [
|
||||||
|
["🔏 Ed25519", "Signe le manifest et la réponse de validation license. La clé publique est embarquée dans le launcher, donc même un MITM sur HTTPS ne peut pas falsifier. Sodium PHP natif côté serveur, NSec.Cryptography côté C#.", COL.accent],
|
||||||
|
["🔗 HMAC-SHA256", "URLs de download présignées par le serveur (path|exp|licId|secret). Apache route via gate.php qui vérifie en constant-time avant de servir le ZIP. Validité 1h, lié à la license.", COL.amber],
|
||||||
|
["💾 DPAPI local", "La clé license est stockée chiffrée dans %LocalAppData%/PSLauncher/config.json via ProtectedData.Protect (scope CurrentUser). Copier ce fichier sur une autre machine ou un autre user → ne marche pas.", COL.green],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.55;
|
||||||
|
blocks.forEach(([title, desc, color]) => {
|
||||||
|
addAccentCard(s, 0.5, y, W - 1, 1.05, color);
|
||||||
|
s.addText(title, {
|
||||||
|
x: 0.75, y: y + 0.15, w: 3, h: 0.35,
|
||||||
|
fontFace: F_TITLE, fontSize: 15, bold: true, color, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 0.75, y: y + 0.5, w: W - 1.2, h: 0.55,
|
||||||
|
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 1.15;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 15: FILES & LOGS =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Fichiers & logs", "section 7 · localisation");
|
||||||
|
addFooter(s, 15);
|
||||||
|
|
||||||
|
// Server side
|
||||||
|
s.addText("Côté serveur OVH", {
|
||||||
|
x: 0.5, y: 1.4, w: 4.3, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.accent, margin: 0
|
||||||
|
});
|
||||||
|
addCodeBlock(s, 0.5, 1.75, 4.3, 3.3, [
|
||||||
|
"www/PS_Launcher/",
|
||||||
|
"├── api/",
|
||||||
|
"│ ├── config.php (gitignored)",
|
||||||
|
"│ ├── index.php",
|
||||||
|
"│ ├── lib/",
|
||||||
|
"│ └── routes/",
|
||||||
|
"├── admin/ (backoffice)",
|
||||||
|
"├── manifest/versions.json",
|
||||||
|
"├── releasenotes/X.Y.Z.md",
|
||||||
|
"├── builds/",
|
||||||
|
"│ ├── proserve-X.Y.Z.zip",
|
||||||
|
"│ └── launcher/",
|
||||||
|
"│ └── PSLauncher-X.Y.Z.exe",
|
||||||
|
"├── tools/sign-manifest.php",
|
||||||
|
"└── migrations/001_init.sql",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Client side
|
||||||
|
s.addText("Côté client Windows", {
|
||||||
|
x: 5.2, y: 1.4, w: 4.3, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.accent, margin: 0
|
||||||
|
});
|
||||||
|
addCodeBlock(s, 5.2, 1.75, 4.3, 3.3, [
|
||||||
|
"%LocalAppData%/PSLauncher/",
|
||||||
|
"├── config.json",
|
||||||
|
"│ (license chiffrée DPAPI)",
|
||||||
|
"├── logs/",
|
||||||
|
"│ ├── app-YYYYMMDD.log",
|
||||||
|
"│ └── updater.log",
|
||||||
|
"├── downloads/",
|
||||||
|
"│ ├── *.partial",
|
||||||
|
"│ └── *.state.json",
|
||||||
|
"└── selfupdate/",
|
||||||
|
"",
|
||||||
|
"C:\\ASTERION_VR/",
|
||||||
|
" (default installRoot)",
|
||||||
|
" ├── Proserve v1.4.5/",
|
||||||
|
" ├── Proserve v1.4.6/",
|
||||||
|
" └── ...",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 16: TROUBLESHOOTING =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Troubleshooting", "section 7 · pannes courantes");
|
||||||
|
addFooter(s, 16);
|
||||||
|
|
||||||
|
const issues = [
|
||||||
|
["⛔", "500 sur l'API", "Vérifier api/config.php existe + valeurs renseignées (debug endpoint /api/debug). Logs PHP : Manager OVH → Logs.", COL.red],
|
||||||
|
["⚠", "Hash incorrect côté client", "Le ZIP a changé sans resync. Backoffice → Versions → bouton bleu 🔁 pour resigner.", COL.amber],
|
||||||
|
["⚠", "License invalide après désactivation", "Cache local toujours valide 7 jours. Désactiver/réactiver depuis Settings → ⚙ ou supprimer config.json.", COL.amber],
|
||||||
|
["⛔", "Updater ne relance pas", "Vérifier %LocalAppData%/PSLauncher/logs/updater.log. Si UAC refusé en Program Files : copier manuellement le binaire.", COL.red],
|
||||||
|
["✓", "Tout marche mais pas d'auto-update", "Section 'launcher' absente du manifest. Onglet Launcher → Définir + 🔁.", COL.green],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.55;
|
||||||
|
issues.forEach(([icon, title, desc, color]) => {
|
||||||
|
addCard(s, 0.5, y, W - 1, 0.65);
|
||||||
|
s.addText(icon, {
|
||||||
|
x: 0.7, y: y + 0.1, w: 0.5, h: 0.45,
|
||||||
|
fontFace: F_TITLE, fontSize: 18, bold: true, color, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
s.addText(title, {
|
||||||
|
x: 1.2, y: y + 0.07, w: 3, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 12, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 1.2, y: y + 0.32, w: W - 1.7, h: 0.32,
|
||||||
|
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.72;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 17: CLOSING =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
|
||||||
|
// Diagonal band
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0, y: 1.3, w: W, h: 1.5, fill: { color: COL.accentDk }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0, y: 1.3, w: W, h: 0.05, fill: { color: COL.accent }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
|
||||||
|
s.addText("Fin de la documentation interne", {
|
||||||
|
x: 0.5, y: 1.55, w: W - 1, h: 0.6,
|
||||||
|
fontFace: F_TITLE, fontSize: 30, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Pour le guide utilisateur destiné aux clients : voir document séparé.", {
|
||||||
|
x: 0.5, y: 2.1, w: W - 1, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 14, italic: true, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Three quick-reference cards
|
||||||
|
const ix = 0.5, iy = 3.4, iw = 3.0, gap = 0.15;
|
||||||
|
const refs = [
|
||||||
|
["📂 Repo", "C:\\ASTERION\\GIT\\PS_Launcher", COL.accent],
|
||||||
|
["🌐 Backoffice", "asterionvr.com/PS_Launcher/admin/", COL.green],
|
||||||
|
["📋 Logs", "%LocalAppData%\\PSLauncher\\logs\\", COL.amber],
|
||||||
|
];
|
||||||
|
refs.forEach(([title, path, color], i) => {
|
||||||
|
const x = ix + i * (iw + gap);
|
||||||
|
addAccentCard(s, x, iy, iw, 1.1, color);
|
||||||
|
s.addText(title, {
|
||||||
|
x: x + 0.2, y: iy + 0.15, w: iw - 0.4, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(path, {
|
||||||
|
x: x + 0.2, y: iy + 0.5, w: iw - 0.4, h: 0.55,
|
||||||
|
fontFace: F_CODE, fontSize: 10, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Footer brand
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0, y: H - 0.5, w: W, h: 0.5, fill: { color: COL.card }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addText("© 2026 ASTERION VR — All rights reserved", {
|
||||||
|
x: 0.5, y: H - 0.45, w: W - 1, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pres.writeFile({ fileName: "C:\\ASTERION\\GIT\\PS_Launcher\\docs\\PS_Launcher-Documentation-Interne.pptx" })
|
||||||
|
.then(f => console.log("Generated:", f));
|
||||||
793
docs/_build_user.js
Normal file
793
docs/_build_user.js
Normal file
@@ -0,0 +1,793 @@
|
|||||||
|
// Generates PS_Launcher-Guide-Utilisateur.pptx
|
||||||
|
const pptxgen = require("pptxgenjs");
|
||||||
|
|
||||||
|
const COL = {
|
||||||
|
bg: "000000",
|
||||||
|
card: "161B23",
|
||||||
|
elev: "1E2632",
|
||||||
|
border: "2A2F3A",
|
||||||
|
text: "F2F2F2",
|
||||||
|
muted: "A0A0A8",
|
||||||
|
accent: "3B82F6",
|
||||||
|
accentDk: "1F3A66",
|
||||||
|
green: "16A34A",
|
||||||
|
greenDk: "0E2A1B",
|
||||||
|
amber: "F59E0B",
|
||||||
|
amberDk: "3A2A0E",
|
||||||
|
red: "EF4444",
|
||||||
|
redDk: "2A1414",
|
||||||
|
codeBg: "0A0E14",
|
||||||
|
};
|
||||||
|
const F_TITLE = "Calibri";
|
||||||
|
const F_BODY = "Calibri";
|
||||||
|
const F_CODE = "Consolas";
|
||||||
|
|
||||||
|
const pres = new pptxgen();
|
||||||
|
pres.layout = "LAYOUT_16x9";
|
||||||
|
pres.author = "ASTERION VR";
|
||||||
|
pres.title = "PROSERVE Launcher — Guide Utilisateur";
|
||||||
|
pres.company = "ASTERION VR";
|
||||||
|
|
||||||
|
const W = 10, H = 5.625;
|
||||||
|
|
||||||
|
// ---- helpers (same as internal but kept local to avoid module fuss) ----
|
||||||
|
function fillBg(slide) { slide.background = { color: COL.bg }; }
|
||||||
|
|
||||||
|
function addHeader(slide, title, kicker) {
|
||||||
|
slide.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0, y: 0, w: W, h: 0.06, fill: { color: COL.green }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
if (kicker) {
|
||||||
|
slide.addText(kicker.toUpperCase(), {
|
||||||
|
x: 0.5, y: 0.25, w: W - 1, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 11, bold: true,
|
||||||
|
color: COL.green, charSpacing: 4, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
slide.addText(title, {
|
||||||
|
x: 0.5, y: kicker ? 0.55 : 0.35, w: W - 1, h: 0.7,
|
||||||
|
fontFace: F_TITLE, fontSize: 32, bold: true,
|
||||||
|
color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
slide.addShape(pres.shapes.LINE, {
|
||||||
|
x: 0.5, y: kicker ? 1.3 : 1.15, w: 1.2, h: 0,
|
||||||
|
line: { color: COL.green, width: 2 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFooter(slide, page) {
|
||||||
|
slide.addText("PROSERVE Launcher • Guide Utilisateur • ASTERION VR", {
|
||||||
|
x: 0.5, y: H - 0.35, w: W - 2, h: 0.25,
|
||||||
|
fontFace: F_BODY, fontSize: 9, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
if (page) {
|
||||||
|
slide.addText(String(page), {
|
||||||
|
x: W - 1, y: H - 0.35, w: 0.5, h: 0.25,
|
||||||
|
fontFace: F_BODY, fontSize: 9, color: COL.muted, align: "right", margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCard(slide, x, y, w, h, options = {}) {
|
||||||
|
slide.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x, y, w, h,
|
||||||
|
fill: { color: options.fill || COL.card },
|
||||||
|
line: { color: options.border || COL.border, width: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAccentCard(slide, x, y, w, h, accentColor) {
|
||||||
|
addCard(slide, x, y, w, h);
|
||||||
|
slide.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x, y, w: 0.08, h,
|
||||||
|
fill: { color: accentColor }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCodeBlock(slide, x, y, w, h, lines) {
|
||||||
|
slide.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x, y, w, h, fill: { color: COL.codeBg }, line: { color: COL.border, width: 1 }
|
||||||
|
});
|
||||||
|
const text = (Array.isArray(lines) ? lines : [lines]).map((line, i, arr) => ({
|
||||||
|
text: line,
|
||||||
|
options: { breakLine: i < arr.length - 1 }
|
||||||
|
}));
|
||||||
|
slide.addText(text, {
|
||||||
|
x: x + 0.15, y: y + 0.1, w: w - 0.3, h: h - 0.2,
|
||||||
|
fontFace: F_CODE, fontSize: 11, color: COL.text, margin: 0,
|
||||||
|
valign: "top"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBullets(slide, x, y, w, h, items, opts = {}) {
|
||||||
|
const fontSize = opts.fontSize || 14;
|
||||||
|
const color = opts.color || COL.text;
|
||||||
|
const arr = items.map((it, i) => ({
|
||||||
|
text: it,
|
||||||
|
options: { bullet: { code: "25A0" }, breakLine: i < items.length - 1, color }
|
||||||
|
}));
|
||||||
|
slide.addText(arr, {
|
||||||
|
x, y, w, h, fontFace: F_BODY, fontSize, color, margin: 0,
|
||||||
|
paraSpaceAfter: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 1: COVER =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
|
||||||
|
// Big band on left
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0, y: 0, w: 3.5, h: H, fill: { color: COL.greenDk }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 3.45, y: 0, w: 0.05, h: H, fill: { color: COL.green }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Brand
|
||||||
|
s.addText("ASTERION VR", {
|
||||||
|
x: 0.4, y: 0.5, w: 3, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 13, bold: true,
|
||||||
|
color: COL.green, charSpacing: 8, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Big right title
|
||||||
|
s.addText("PROSERVE", {
|
||||||
|
x: 3.9, y: 1.3, w: 6, h: 1,
|
||||||
|
fontFace: F_TITLE, fontSize: 60, bold: true,
|
||||||
|
color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Launcher", {
|
||||||
|
x: 3.9, y: 2.1, w: 6, h: 0.7,
|
||||||
|
fontFace: F_TITLE, fontSize: 40, bold: true,
|
||||||
|
color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
s.addText("Guide utilisateur", {
|
||||||
|
x: 3.9, y: 3.0, w: 6, h: 0.5,
|
||||||
|
fontFace: F_TITLE, fontSize: 22,
|
||||||
|
color: COL.green, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Installation, activation et utilisation", {
|
||||||
|
x: 3.9, y: 3.5, w: 6, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 14, italic: true,
|
||||||
|
color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Left band content
|
||||||
|
s.addText("v0.8", {
|
||||||
|
x: 0.4, y: 2, w: 3, h: 1,
|
||||||
|
fontFace: F_TITLE, fontSize: 56, bold: true,
|
||||||
|
color: COL.text, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Mai 2026", {
|
||||||
|
x: 0.4, y: 2.95, w: 3, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 13,
|
||||||
|
color: COL.muted, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bottom strip
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0, y: H - 0.5, w: W, h: 0.5, fill: { color: COL.card }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addText("© 2026 ASTERION VR — Tous droits réservés", {
|
||||||
|
x: 0.5, y: H - 0.45, w: W - 1, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 2: WHAT IS IT =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Qu'est-ce que PROSERVE Launcher ?", "introduction");
|
||||||
|
addFooter(s, 2);
|
||||||
|
|
||||||
|
s.addText("Une application Windows qui te permet de :", {
|
||||||
|
x: 0.5, y: 1.4, w: W - 1, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 16, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
["📦", "Installer", "Télécharge et déploie chaque nouvelle version de Proserve sur ta machine en quelques clics."],
|
||||||
|
["▶", "Lancer", "Démarre la version de ton choix. Plusieurs versions peuvent cohabiter sur le même PC."],
|
||||||
|
["🔄", "Mettre à jour", "Détecte automatiquement les nouvelles versions disponibles côté serveur ASTERION et propose la mise à jour."],
|
||||||
|
["🔑", "License", "Vérifie ta license en ligne (ou via cache local 7 jours en cas de coupure réseau)."],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 2.0;
|
||||||
|
features.forEach(([icon, title, desc]) => {
|
||||||
|
addCard(s, 0.5, y, W - 1, 0.7);
|
||||||
|
s.addText(icon, {
|
||||||
|
x: 0.7, y: y + 0.13, w: 0.6, h: 0.45,
|
||||||
|
fontFace: F_TITLE, fontSize: 20, color: COL.green, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
s.addText(title, {
|
||||||
|
x: 1.4, y: y + 0.07, w: 2, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 1.4, y: y + 0.34, w: W - 1.9, h: 0.35,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.78;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 3: PREREQUISITES =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Pré-requis", "avant de commencer");
|
||||||
|
addFooter(s, 3);
|
||||||
|
|
||||||
|
const prereqs = [
|
||||||
|
["💻", "Windows 10 ou 11", "Édition 64-bit. Pas de version macOS / Linux pour l'instant."],
|
||||||
|
["🌐", "Connexion internet", "Requise pour activer la license et télécharger les versions. Le launcher fonctionne hors-ligne pendant 7 jours après la dernière validation."],
|
||||||
|
["💾", "Espace disque", "Prévoir ~14 Go par version installée + ~14 Go de cache temporaire pendant le téléchargement."],
|
||||||
|
["🔑", "Une clé license", "Format PRSRV-XXXX-XXXX-XXXX-XXXX, fournie par ASTERION VR."],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.5;
|
||||||
|
prereqs.forEach(([icon, title, desc]) => {
|
||||||
|
addAccentCard(s, 0.5, y, W - 1, 0.85, COL.green);
|
||||||
|
s.addText(icon, {
|
||||||
|
x: 0.75, y: y + 0.18, w: 0.7, h: 0.5,
|
||||||
|
fontFace: F_TITLE, fontSize: 22, color: COL.green, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
s.addText(title, {
|
||||||
|
x: 1.5, y: y + 0.12, w: 6, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 1.5, y: y + 0.42, w: W - 2, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.95;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 4: INSTALLATION =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Installation", "première fois");
|
||||||
|
addFooter(s, 4);
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
["1", "Télécharge le setup", "Tu reçois un fichier nommé PSLauncher-Setup-X.Y.Z.exe (~80 Mo) — par email, lien WeTransfer, ou via le site ASTERION."],
|
||||||
|
["2", "Double-clique pour lancer", "Windows peut afficher un avertissement SmartScreen « Éditeur inconnu ».\nClique « Informations complémentaires » → « Exécuter quand même »."],
|
||||||
|
["3", "Suis l'assistant", "Choisis la langue, valide les chemins par défaut. Le launcher s'installe dans ton profil utilisateur — pas besoin de droits administrateur."],
|
||||||
|
["4", "Lance PROSERVE Launcher", "Un raccourci apparaît sur le bureau et dans le menu Démarrer."],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.5;
|
||||||
|
steps.forEach(([num, title, desc]) => {
|
||||||
|
addCard(s, 0.5, y, W - 1, 0.85);
|
||||||
|
s.addShape(pres.shapes.OVAL, {
|
||||||
|
x: 0.7, y: y + 0.2, w: 0.45, h: 0.45,
|
||||||
|
fill: { color: COL.green }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addText(num, {
|
||||||
|
x: 0.7, y: y + 0.2, w: 0.45, h: 0.45,
|
||||||
|
fontFace: F_TITLE, fontSize: 16, bold: true,
|
||||||
|
color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
s.addText(title, {
|
||||||
|
x: 1.4, y: y + 0.1, w: W - 2, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 1.4, y: y + 0.4, w: W - 2, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.95;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 5: ACTIVATE LICENSE =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Activer ta license", "premier démarrage");
|
||||||
|
addFooter(s, 5);
|
||||||
|
|
||||||
|
s.addText("Au premier lancement, le badge en haut au centre de la fenêtre indique « 🔒 Aucune license » en rouge.", {
|
||||||
|
x: 0.5, y: 1.4, w: W - 1, h: 0.45,
|
||||||
|
fontFace: F_BODY, fontSize: 13, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Badge mockup
|
||||||
|
addAccentCard(s, 0.5, 1.95, 4.5, 1.6, COL.red);
|
||||||
|
s.addText("🔒 Aucune license", {
|
||||||
|
x: 0.7, y: 2.05, w: 4, h: 0.4,
|
||||||
|
fontFace: F_TITLE, fontSize: 16, bold: true, color: COL.red, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Visible dans la barre du haut au centre", {
|
||||||
|
x: 0.7, y: 2.4, w: 4, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 11, italic: true, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("👉 Clique dessus !", {
|
||||||
|
x: 0.7, y: 2.85, w: 4, h: 0.5,
|
||||||
|
fontFace: F_TITLE, fontSize: 16, bold: true, color: COL.green, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then
|
||||||
|
addAccentCard(s, 5.2, 1.95, 4.3, 1.6, COL.green);
|
||||||
|
s.addText("Dialog d'activation", {
|
||||||
|
x: 5.4, y: 2.05, w: 4, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
addBullets(s, 5.4, 2.4, 4, 1.1, [
|
||||||
|
"Colle ta clé PRSRV-…",
|
||||||
|
"Clique « Activer »",
|
||||||
|
"Le badge passe en vert ✓",
|
||||||
|
], { fontSize: 11 });
|
||||||
|
|
||||||
|
// Result
|
||||||
|
addCard(s, 0.5, 3.85, W - 1, 1.0);
|
||||||
|
s.addText("Une fois activée :", {
|
||||||
|
x: 0.7, y: 3.95, w: 4, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.green, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("La barre indique le nom du client + la date de validité. Tu peux maintenant télécharger les versions de Proserve auxquelles ta license te donne accès.", {
|
||||||
|
x: 0.7, y: 4.25, w: W - 1.4, h: 0.6,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 6: LIBRARY OVERVIEW =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "La fenêtre principale", "ta bibliothèque");
|
||||||
|
addFooter(s, 6);
|
||||||
|
|
||||||
|
// Hero card simulated
|
||||||
|
addAccentCard(s, 0.5, 1.4, W - 1, 1.2, COL.green);
|
||||||
|
s.addText("VERSION COURANTE", {
|
||||||
|
x: 0.75, y: 1.5, w: 4, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 10, bold: true, color: COL.muted, charSpacing: 4, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Proserve v1.4.7", {
|
||||||
|
x: 0.75, y: 1.78, w: 5, h: 0.5,
|
||||||
|
fontFace: F_TITLE, fontSize: 24, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("● Installée • Sortie le 29/04/2026 • 14 Go", {
|
||||||
|
x: 0.75, y: 2.25, w: 5, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
// Big launcher button mockup
|
||||||
|
s.addShape(pres.shapes.ROUNDED_RECTANGLE, {
|
||||||
|
x: 7.4, y: 1.7, w: 1.8, h: 0.6,
|
||||||
|
fill: { color: COL.green }, line: { type: "none" }, rectRadius: 0.05
|
||||||
|
});
|
||||||
|
s.addText("▶ LANCER", {
|
||||||
|
x: 7.4, y: 1.7, w: 1.8, h: 0.6,
|
||||||
|
fontFace: F_TITLE, fontSize: 16, bold: true,
|
||||||
|
color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Section header
|
||||||
|
s.addText("AUTRES VERSIONS", {
|
||||||
|
x: 0.5, y: 2.85, w: 3, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 10, bold: true, color: COL.muted, charSpacing: 4, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Other rows
|
||||||
|
const rowY1 = 3.2, rowY2 = 4.0;
|
||||||
|
addAccentCard(s, 0.5, rowY1, W - 1, 0.7, COL.green);
|
||||||
|
s.addText("v1.4.6 ● Installée", {
|
||||||
|
x: 0.75, y: rowY1 + 0.1, w: 5, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("14 Go • 15/04/2026", {
|
||||||
|
x: 0.75, y: rowY1 + 0.4, w: 5, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
s.addShape(pres.shapes.ROUNDED_RECTANGLE, {
|
||||||
|
x: 8.3, y: rowY1 + 0.18, w: 1.05, h: 0.4,
|
||||||
|
fill: { color: COL.green }, line: { type: "none" }, rectRadius: 0.05
|
||||||
|
});
|
||||||
|
s.addText("▶ Lancer", {
|
||||||
|
x: 8.3, y: rowY1 + 0.18, w: 1.05, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, bold: true, color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
addAccentCard(s, 0.5, rowY2, W - 1, 0.7, COL.accent);
|
||||||
|
s.addText("v1.4.5 ○ Disponible", {
|
||||||
|
x: 0.75, y: rowY2 + 0.1, w: 5, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("448 Mo • 15/04/2026", {
|
||||||
|
x: 0.75, y: rowY2 + 0.4, w: 5, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
s.addShape(pres.shapes.ROUNDED_RECTANGLE, {
|
||||||
|
x: 8.1, y: rowY2 + 0.18, w: 1.25, h: 0.4,
|
||||||
|
fill: { color: COL.accent }, line: { type: "none" }, rectRadius: 0.05
|
||||||
|
});
|
||||||
|
s.addText("⬇ Installer", {
|
||||||
|
x: 8.1, y: rowY2 + 0.18, w: 1.25, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, bold: true, color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 7: LAUNCH A VERSION =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Lancer une version", "▶ jouer");
|
||||||
|
addFooter(s, 7);
|
||||||
|
|
||||||
|
s.addText("Deux possibilités :", {
|
||||||
|
x: 0.5, y: 1.4, w: W - 1, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 14, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
addAccentCard(s, 0.5, 1.85, 4.5, 2.5, COL.green);
|
||||||
|
s.addText("LA PLUS RÉCENTE", {
|
||||||
|
x: 0.7, y: 1.95, w: 4, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 11, bold: true, color: COL.green, charSpacing: 3, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Le gros bouton vert dans la carte « Version courante » lance la version la plus récente que tu as installée.", {
|
||||||
|
x: 0.7, y: 2.3, w: 4, h: 1.2,
|
||||||
|
fontFace: F_BODY, fontSize: 12, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addShape(pres.shapes.ROUNDED_RECTANGLE, {
|
||||||
|
x: 0.7, y: 3.55, w: 4, h: 0.6,
|
||||||
|
fill: { color: COL.green }, line: { type: "none" }, rectRadius: 0.05
|
||||||
|
});
|
||||||
|
s.addText("▶ LANCER", {
|
||||||
|
x: 0.7, y: 3.55, w: 4, h: 0.6,
|
||||||
|
fontFace: F_TITLE, fontSize: 16, bold: true,
|
||||||
|
color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
addAccentCard(s, 5.2, 1.85, 4.3, 2.5, COL.green);
|
||||||
|
s.addText("UNE VERSION ANCIENNE", {
|
||||||
|
x: 5.4, y: 1.95, w: 4, h: 0.3,
|
||||||
|
fontFace: F_BODY, fontSize: 11, bold: true, color: COL.green, charSpacing: 3, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Dans la section « Autres versions », chaque ligne installée a son propre bouton ▶ Lancer pour démarrer cette version précisément.", {
|
||||||
|
x: 5.4, y: 2.3, w: 4, h: 1.5,
|
||||||
|
fontFace: F_BODY, fontSize: 12, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Behavior
|
||||||
|
addCard(s, 0.5, 4.55, W - 1, 0.7);
|
||||||
|
s.addText("ℹ", {
|
||||||
|
x: 0.7, y: 4.65, w: 0.5, h: 0.5,
|
||||||
|
fontFace: F_TITLE, fontSize: 18, bold: true, color: COL.accent, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
s.addText("La fenêtre du launcher se réduit automatiquement dans la barre des tâches pour laisser la place à Proserve.", {
|
||||||
|
x: 1.2, y: 4.7, w: W - 1.7, h: 0.5,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 8: INSTALL NEW VERSION =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Installer une nouvelle version", "📦 télécharger");
|
||||||
|
addFooter(s, 8);
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
["1", "Vérifie les MAJ", "Bouton « 🔄 Vérifier les MAJ » en bas à gauche.\nLe launcher contacte le serveur et liste les nouvelles versions."],
|
||||||
|
["2", "Repère la ligne bleue", "Une ligne « ○ Disponible » apparaît avec un bouton bleu « ⬇ Installer X.Y.Z »."],
|
||||||
|
["3", "Clique « ⬇ Installer »", "Une fenêtre s'ouvre avec les notes de version (Markdown). Lis et clique « Télécharger » pour confirmer."],
|
||||||
|
["4", "Patience pendant le DL", "La barre de progression en bas montre la vitesse, l'avancement, l'ETA. Tu peux mettre en pause ou annuler."],
|
||||||
|
["5", "Installation auto", "Une fois le ZIP vérifié (SHA-256), il est extrait dans le dossier d'install à côté des autres versions."],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.45;
|
||||||
|
steps.forEach(([num, title, desc]) => {
|
||||||
|
addCard(s, 0.5, y, W - 1, 0.7);
|
||||||
|
s.addShape(pres.shapes.OVAL, {
|
||||||
|
x: 0.7, y: y + 0.13, w: 0.4, h: 0.4,
|
||||||
|
fill: { color: COL.accent }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addText(num, {
|
||||||
|
x: 0.7, y: y + 0.13, w: 0.4, h: 0.4,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true,
|
||||||
|
color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
s.addText(title, {
|
||||||
|
x: 1.3, y: y + 0.07, w: 8, h: 0.27,
|
||||||
|
fontFace: F_TITLE, fontSize: 12, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 1.3, y: y + 0.32, w: 8, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.78;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 9: PROGRESS / RESUME =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Suivre un téléchargement", "barre du bas");
|
||||||
|
addFooter(s, 9);
|
||||||
|
|
||||||
|
// Mock footer
|
||||||
|
addCard(s, 0.5, 1.5, W - 1, 0.7);
|
||||||
|
s.addText("⬇ v1.4.7 : 4,2 / 14,0 Go • 38 Mo/s • ETA 4 min", {
|
||||||
|
x: 0.75, y: 1.6, w: 7.5, h: 0.3,
|
||||||
|
fontFace: F_CODE, fontSize: 12, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
// Progress bar
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0.75, y: 1.95, w: 7.5, h: 0.1, fill: { color: "2A2A30" }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0.75, y: 1.95, w: 2.25, h: 0.1, fill: { color: COL.accent }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
// Cancel button
|
||||||
|
s.addShape(pres.shapes.ROUNDED_RECTANGLE, {
|
||||||
|
x: 8.5, y: 1.62, w: 0.85, h: 0.4,
|
||||||
|
fill: { color: "3A3A40" }, line: { type: "none" }, rectRadius: 0.05
|
||||||
|
});
|
||||||
|
s.addText("Annuler", {
|
||||||
|
x: 8.5, y: 1.62, w: 0.85, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.text, align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Features
|
||||||
|
s.addText("Ce que tu peux faire pendant le DL", {
|
||||||
|
x: 0.5, y: 2.5, w: W - 1, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
addBullets(s, 0.5, 2.85, W - 1, 1.1, [
|
||||||
|
"Annuler à tout moment (le ZIP partiel est conservé pour reprendre plus tard)",
|
||||||
|
"Fermer le launcher : la prochaine ouverture proposera de reprendre",
|
||||||
|
"Couper le réseau : le launcher attend (retry exponentiel) puis reprend automatiquement",
|
||||||
|
], { fontSize: 12 });
|
||||||
|
|
||||||
|
// Resume callout
|
||||||
|
addAccentCard(s, 0.5, 4.15, W - 1, 0.95, COL.amber);
|
||||||
|
s.addText("↻ Reprendre", {
|
||||||
|
x: 0.75, y: 4.25, w: 2, h: 0.35,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.amber, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Un DL interrompu apparaît avec un bouton « ↻ Reprendre (X%) » à la place de « Installer ». Le téléchargement reprend là où il s'était arrêté — pas besoin de tout recommencer.", {
|
||||||
|
x: 0.75, y: 4.55, w: W - 1.5, h: 0.6,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 10: VERSION MENU =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Menu d'une version", "bouton ⋯");
|
||||||
|
addFooter(s, 10);
|
||||||
|
|
||||||
|
s.addText("Le bouton « ⋯ » à droite de chaque ligne ouvre un menu contextuel.", {
|
||||||
|
x: 0.5, y: 1.4, w: W - 1, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 13, color: COL.muted, italic: true, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Menu mockup
|
||||||
|
addCard(s, 0.5, 1.95, 4.5, 2.4);
|
||||||
|
s.addText("📜 Voir les release notes", {
|
||||||
|
x: 0.75, y: 2.1, w: 4, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 13, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addShape(pres.shapes.LINE, {
|
||||||
|
x: 0.75, y: 2.5, w: 4, h: 0,
|
||||||
|
line: { color: COL.border, width: 0.5 }
|
||||||
|
});
|
||||||
|
s.addText("📁 Ouvrir le dossier", {
|
||||||
|
x: 0.75, y: 2.6, w: 4, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 13, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addShape(pres.shapes.LINE, {
|
||||||
|
x: 0.75, y: 3.0, w: 4, h: 0,
|
||||||
|
line: { color: COL.border, width: 0.5 }
|
||||||
|
});
|
||||||
|
s.addText("🗑 Supprimer cette version…", {
|
||||||
|
x: 0.75, y: 3.1, w: 4, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 13, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Description
|
||||||
|
addBullets(s, 5.3, 2.1, 4.2, 2.4, [
|
||||||
|
"Release notes : disponibles aussi sur les versions non installées",
|
||||||
|
"Ouvrir le dossier : utile pour vérifier l'installation",
|
||||||
|
"Supprimer : libère l'espace disque (≈ 14 Go) avec confirmation. Garde tes anciennes versions tant que tu veux pouvoir y revenir.",
|
||||||
|
], { fontSize: 11 });
|
||||||
|
|
||||||
|
// Tip
|
||||||
|
addAccentCard(s, 0.5, 4.55, W - 1, 0.65, COL.green);
|
||||||
|
s.addText("✓ Aucune suppression automatique : c'est toujours toi qui décides quand effacer une vieille version.", {
|
||||||
|
x: 0.75, y: 4.65, w: W - 1.5, h: 0.5,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 11: SETTINGS =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Paramètres ⚙", "bouton en haut à droite");
|
||||||
|
addFooter(s, 11);
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
["🔑", "License", "Statut, propriétaire, date d'expiration. Identifiant machine anonyme (à communiquer à ASTERION en cas de souci de slot machine)."],
|
||||||
|
["🌐", "Serveur", "URL de l'API ASTERION. À ne changer que si tu y es invité par le support."],
|
||||||
|
["📁", "Installation", "Dossier où sont posées les versions de Proserve. Par défaut C:\\ASTERION_VR. Bouton « Parcourir » pour changer."],
|
||||||
|
["💾", "Cache", "Taille du cache de téléchargements. Bouton « Vider » pour libérer de l'espace si besoin."],
|
||||||
|
["📋", "Logs", "Dossier des logs (.log) — utile en cas de souci, à transmettre au support."],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.4;
|
||||||
|
sections.forEach(([icon, title, desc]) => {
|
||||||
|
addCard(s, 0.5, y, W - 1, 0.7);
|
||||||
|
s.addText(icon, {
|
||||||
|
x: 0.7, y: y + 0.12, w: 0.5, h: 0.45,
|
||||||
|
fontFace: F_TITLE, fontSize: 18, color: COL.green, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
s.addText(title, {
|
||||||
|
x: 1.25, y: y + 0.07, w: 2, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 1.25, y: y + 0.33, w: W - 1.7, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.78;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 12: AUTO-UPDATE =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "Mises à jour du launcher", "🔄 automatiques");
|
||||||
|
addFooter(s, 12);
|
||||||
|
|
||||||
|
s.addText("Le launcher se met à jour tout seul quand ASTERION pousse une nouvelle version.", {
|
||||||
|
x: 0.5, y: 1.4, w: W - 1, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 13, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const flow = [
|
||||||
|
["1", "Détection", "Au démarrage et à chaque clic « Vérifier les MAJ », le launcher contacte le serveur."],
|
||||||
|
["2", "Notification", "Si une nouvelle version du launcher existe, une popup t'informe et propose de mettre à jour."],
|
||||||
|
["3", "Téléchargement", "Quelques secondes (le launcher fait ~80 Mo)."],
|
||||||
|
["4", "Redémarrage automatique", "L'application se ferme, est remplacée, puis se relance avec la nouvelle version."],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.95;
|
||||||
|
flow.forEach(([num, title, desc]) => {
|
||||||
|
addCard(s, 0.5, y, W - 1, 0.65);
|
||||||
|
s.addShape(pres.shapes.OVAL, {
|
||||||
|
x: 0.7, y: y + 0.12, w: 0.4, h: 0.4,
|
||||||
|
fill: { color: COL.green }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addText(num, {
|
||||||
|
x: 0.7, y: y + 0.12, w: 0.4, h: 0.4,
|
||||||
|
fontFace: F_TITLE, fontSize: 14, bold: true,
|
||||||
|
color: "FFFFFF", align: "center", valign: "middle", margin: 0
|
||||||
|
});
|
||||||
|
s.addText(title, {
|
||||||
|
x: 1.3, y: y + 0.07, w: 8, h: 0.27,
|
||||||
|
fontFace: F_TITLE, fontSize: 12, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(desc, {
|
||||||
|
x: 1.3, y: y + 0.32, w: 8, h: 0.32,
|
||||||
|
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.73;
|
||||||
|
});
|
||||||
|
|
||||||
|
addAccentCard(s, 0.5, 4.95, W - 1, 0.5, COL.green);
|
||||||
|
s.addText("✓ Tu peux toujours refuser et reporter — clique « Plus tard ».", {
|
||||||
|
x: 0.75, y: 5.0, w: W - 1.5, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 13: FAQ =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
addHeader(s, "FAQ", "questions fréquentes");
|
||||||
|
addFooter(s, 13);
|
||||||
|
|
||||||
|
const qs = [
|
||||||
|
["❓", "Ma license est expirée. Que se passe-t-il ?",
|
||||||
|
"Tu peux toujours utiliser les versions déjà installées. Seul le téléchargement de nouvelles versions est bloqué jusqu'à renouvellement.",
|
||||||
|
COL.amber],
|
||||||
|
["❓", "Pas de connexion internet — le launcher marche-t-il ?",
|
||||||
|
"Oui, pendant 7 jours après la dernière validation en ligne. Au-delà, la license est considérée comme à revérifier.",
|
||||||
|
COL.accent],
|
||||||
|
["❓", "Comment changer de PC ?",
|
||||||
|
"Désactive ta license sur l'ancien (Settings → bouton Désactiver), puis active-la sur le nouveau avec la même clé.",
|
||||||
|
COL.green],
|
||||||
|
["❓", "Le téléchargement échoue avec « SHA-256 invalide » ?",
|
||||||
|
"Réseau bruité ou ZIP modifié côté serveur entre-temps. Le partial est jeté ; le DL recommence proprement au prochain clic.",
|
||||||
|
COL.red],
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 1.5;
|
||||||
|
qs.forEach(([icon, q, a, color]) => {
|
||||||
|
addAccentCard(s, 0.5, y, W - 1, 0.85, color);
|
||||||
|
s.addText(icon, {
|
||||||
|
x: 0.7, y: y + 0.18, w: 0.4, h: 0.45,
|
||||||
|
fontFace: F_TITLE, fontSize: 16, color, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
s.addText(q, {
|
||||||
|
x: 1.2, y: y + 0.1, w: W - 1.7, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 12, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(a, {
|
||||||
|
x: 1.2, y: y + 0.4, w: W - 1.7, h: 0.45,
|
||||||
|
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
y += 0.95;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== SLIDE 14: SUPPORT =====================
|
||||||
|
{
|
||||||
|
const s = pres.addSlide();
|
||||||
|
fillBg(s);
|
||||||
|
|
||||||
|
// Big band
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0, y: 1.3, w: W, h: 1.3, fill: { color: COL.greenDk }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0, y: 1.3, w: W, h: 0.05, fill: { color: COL.green }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
|
||||||
|
s.addText("Besoin d'aide ?", {
|
||||||
|
x: 0.5, y: 1.55, w: W - 1, h: 0.6,
|
||||||
|
fontFace: F_TITLE, fontSize: 32, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText("Contacte ASTERION VR avec ces infos pour un support efficace :", {
|
||||||
|
x: 0.5, y: 2.1, w: W - 1, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 14, italic: true, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Three cards
|
||||||
|
const ix = 0.5, iy = 3.05, iw = 3.0, gap = 0.15;
|
||||||
|
const refs = [
|
||||||
|
["🔑", "Ta clé license", "PRSRV-XXXX-XXXX-XXXX-XXXX", COL.green],
|
||||||
|
["💻", "Ton ID machine", "Settings → License\nbouton 📋 Copier", COL.accent],
|
||||||
|
["📋", "Tes logs", "Settings → Logs\nbouton 📁 Ouvrir", COL.amber],
|
||||||
|
];
|
||||||
|
refs.forEach(([icon, title, body, color], i) => {
|
||||||
|
const x = ix + i * (iw + gap);
|
||||||
|
addAccentCard(s, x, iy, iw, 1.7, color);
|
||||||
|
s.addText(icon, {
|
||||||
|
x: x + 0.2, y: iy + 0.15, w: iw - 0.4, h: 0.45,
|
||||||
|
fontFace: F_TITLE, fontSize: 22, color, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(title, {
|
||||||
|
x: x + 0.2, y: iy + 0.65, w: iw - 0.4, h: 0.3,
|
||||||
|
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
|
||||||
|
});
|
||||||
|
s.addText(body, {
|
||||||
|
x: x + 0.2, y: iy + 0.95, w: iw - 0.4, h: 0.7,
|
||||||
|
fontFace: F_CODE, fontSize: 10, color: COL.muted, margin: 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Footer brand
|
||||||
|
s.addShape(pres.shapes.RECTANGLE, {
|
||||||
|
x: 0, y: H - 0.5, w: W, h: 0.5, fill: { color: COL.card }, line: { type: "none" }
|
||||||
|
});
|
||||||
|
s.addText("© 2026 ASTERION VR — Tous droits réservés", {
|
||||||
|
x: 0.5, y: H - 0.45, w: W - 1, h: 0.4,
|
||||||
|
fontFace: F_BODY, fontSize: 11, color: COL.muted, align: "center", margin: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pres.writeFile({ fileName: "C:\\ASTERION\\GIT\\PS_Launcher\\docs\\PS_Launcher-Guide-Utilisateur.pptx" })
|
||||||
|
.then(f => console.log("Generated:", f));
|
||||||
@@ -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.5.0"
|
#define MyAppVersion "1.0.13"
|
||||||
#define MyAppPublisher "ASTERION VR"
|
#define MyAppPublisher "ASTERION VR"
|
||||||
#define MyAppURL "https://asterionvr.com"
|
#define MyAppURL "https://asterionvr.com"
|
||||||
#define MyAppExeName "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
|
||||||
@@ -28,17 +28,24 @@ AppPublisher={#MyAppPublisher}
|
|||||||
AppPublisherURL={#MyAppURL}
|
AppPublisherURL={#MyAppURL}
|
||||||
AppSupportURL={#MyAppURL}
|
AppSupportURL={#MyAppURL}
|
||||||
AppUpdatesURL={#MyAppURL}
|
AppUpdatesURL={#MyAppURL}
|
||||||
|
; Installation system-wide dans Program Files. {autopf} résout vers
|
||||||
|
; "C:\Program Files" sur cible 64-bit (cf. ArchitecturesInstallIn64BitMode=x64
|
||||||
|
; ci-dessous). PrivilegesRequired=admin → UAC à l'install, et UAC à chaque
|
||||||
|
; self-update du launcher (LauncherSelfUpdater détecte automatiquement le
|
||||||
|
; dossier non-writable et lance PS_Launcher.Updater.exe avec Verb="runas").
|
||||||
|
; Le staging du nouveau .exe avant swap reste dans %LocalAppData%\PSLauncher\
|
||||||
|
; selfupdate\, user-writable et lisible depuis l'updater élevé.
|
||||||
DefaultDirName={autopf}\{#MyAppPublisher}\{#MyAppShortName}
|
DefaultDirName={autopf}\{#MyAppPublisher}\{#MyAppShortName}
|
||||||
DefaultGroupName={#MyAppName}
|
DefaultGroupName={#MyAppName}
|
||||||
DisableProgramGroupPage=yes
|
DisableProgramGroupPage=yes
|
||||||
OutputDir=output
|
OutputDir=output
|
||||||
OutputBaseFilename=PSLauncher-Setup-{#MyAppVersion}
|
OutputBaseFilename=PS_Launcher-Setup-{#MyAppVersion}
|
||||||
SetupIconFile=
|
SetupIconFile=..\src\PSLauncher.App\Resources\favicon.ico
|
||||||
Compression=lzma2/ultra
|
Compression=lzma2/ultra
|
||||||
SolidCompression=yes
|
SolidCompression=yes
|
||||||
WizardStyle=modern
|
WizardStyle=modern
|
||||||
PrivilegesRequired=admin
|
PrivilegesRequired=admin
|
||||||
PrivilegesRequiredOverridesAllowed=dialog
|
UsedUserAreasWarning=no
|
||||||
ArchitecturesAllowed=x64
|
ArchitecturesAllowed=x64
|
||||||
ArchitecturesInstallIn64BitMode=x64
|
ArchitecturesInstallIn64BitMode=x64
|
||||||
UninstallDisplayName={#MyAppName}
|
UninstallDisplayName={#MyAppName}
|
||||||
@@ -57,18 +64,72 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription
|
|||||||
[Files]
|
[Files]
|
||||||
Source: "..\src\PSLauncher.App\bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\src\PSLauncher.App\bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
Source: "..\src\PSLauncher.Updater\bin\Release\net8.0-windows\win-x64\publish\{#MyUpdaterExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\src\PSLauncher.Updater\bin\Release\net8.0-windows\win-x64\publish\{#MyUpdaterExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "..\docs\PS_Launcher-Guide-Utilisateur.pptx"; DestDir: "{app}\docs"; Flags: ignoreversion
|
||||||
|
; WebView2 Evergreen Bootstrapper (Microsoft, redistribuable). 1.6 MB.
|
||||||
|
; Téléchargé depuis https://go.microsoft.com/fwlink/p/?LinkId=2124703
|
||||||
|
; Exécuté en silencieux à l'install SI WebView2 Runtime n'est pas déjà présent
|
||||||
|
; (vérification registre via la fonction Pascal IsWebView2Installed ci-dessous).
|
||||||
|
; Sans ça, sur un Win10/Server sans WebView2 préinstallé, la fenêtre du launcher
|
||||||
|
; reste toute blanche au démarrage parce que les contrôles WebView2 (Reports +
|
||||||
|
; Documentation) ne peuvent pas s'initialiser et plantent en silence le rendu UI.
|
||||||
|
Source: "redists\MicrosoftEdgeWebview2Setup.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall; Check: not IsWebView2Installed
|
||||||
|
|
||||||
[Icons]
|
[Icons]
|
||||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||||
|
Name: "{group}\Guide utilisateur"; Filename: "{app}\docs\PS_Launcher-Guide-Utilisateur.pptx"; IconFilename: "{app}\{#MyAppExeName}"
|
||||||
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
|
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
|
||||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
|
; Installe WebView2 Runtime si pas déjà présent. /silent /install = install
|
||||||
|
; non-interactif. Le bootstrapper télécharge ~150 MB depuis Microsoft (1-2 min
|
||||||
|
; selon la connexion). StatusMsg pour que l'utilisateur voie ce qu'il se passe.
|
||||||
|
; Code exit attendu : 0 (déjà installé OU install réussie). On ne fail pas le
|
||||||
|
; setup global même si ça échoue — le launcher logue ensuite l'absence si ça
|
||||||
|
; pose problème, plutôt que de bloquer toute l'installation pour ça.
|
||||||
|
Filename: "{tmp}\MicrosoftEdgeWebview2Setup.exe"; Parameters: "/silent /install"; StatusMsg: "Installation du composant Microsoft Edge WebView2 Runtime (~150 MB, peut prendre 1-2 min)..."; Check: not IsWebView2Installed; Flags: waituntilterminated
|
||||||
|
|
||||||
|
; Règles firewall pour le cache LAN P2P. Profile=private,domain (PAS public) =
|
||||||
|
; safety net : même si l'utilisateur connecte le PC à un Wi-Fi public, le port
|
||||||
|
; reste fermé. Le filtre RFC1918 dans LanCacheServer.cs est la première barrière,
|
||||||
|
; ces règles sont la deuxième. runhidden = pas de console visible à l'install.
|
||||||
|
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PSLauncher LAN Cache HTTP"" dir=in action=allow program=""{app}\{#MyAppExeName}"" protocol=TCP localport=47623 profile=private,domain"; Flags: runhidden
|
||||||
|
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PSLauncher LAN Discovery"" dir=in action=allow program=""{app}\{#MyAppExeName}"" protocol=UDP localport=47624 profile=private,domain"; Flags: runhidden
|
||||||
|
|
||||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
|
[UninstallRun]
|
||||||
|
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PSLauncher LAN Cache HTTP"""; Flags: runhidden
|
||||||
|
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PSLauncher LAN Discovery"""; Flags: runhidden
|
||||||
|
|
||||||
[UninstallDelete]
|
[UninstallDelete]
|
||||||
; Ne touche PAS au cache utilisateur (%LocalAppData%\PSLauncher) ni aux versions installées
|
; Ne touche PAS au cache utilisateur (%LocalAppData%\PSLauncher) ni aux versions installées
|
||||||
; (qui peuvent vivre dans n'importe quel installRoot configuré). On supprime uniquement les
|
; (qui peuvent vivre dans n'importe quel installRoot configuré). On supprime uniquement les
|
||||||
; binaires installés et le dossier d'install s'il est vide après.
|
; binaires installés et le dossier d'install s'il est vide après.
|
||||||
Type: filesandordirs; Name: "{app}\*.bak"
|
Type: filesandordirs; Name: "{app}\*.bak"
|
||||||
Type: dirifempty; Name: "{app}"
|
Type: dirifempty; Name: "{app}"
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
// Détecte si Microsoft Edge WebView2 Runtime est déjà installé sur le système.
|
||||||
|
// Référence : https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution
|
||||||
|
//
|
||||||
|
// 3 emplacements registre possibles selon le contexte d'install du runtime :
|
||||||
|
// - HKLM 64-bit (machine-wide, install par admin)
|
||||||
|
// - HKLM 32-bit (machine-wide, parfois utilisé par les versions plus anciennes)
|
||||||
|
// - HKCU (user-only, install par utilisateur sans admin)
|
||||||
|
//
|
||||||
|
// La clé contient une valeur 'pv' avec la version installée. Présence non vide
|
||||||
|
// = runtime présent. Le check Edge classique (HKLM\...\Edge\BLBeacon) ne suffit
|
||||||
|
// pas parce qu'Edge stable ≠ WebView2 Runtime (deux composants distincts).
|
||||||
|
function IsWebView2Installed(): Boolean;
|
||||||
|
var
|
||||||
|
PV: String;
|
||||||
|
begin
|
||||||
|
Result := False;
|
||||||
|
if RegQueryStringValue(HKLM, 'SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
|
||||||
|
Result := True
|
||||||
|
else if RegQueryStringValue(HKLM, 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
|
||||||
|
Result := True
|
||||||
|
else if RegQueryStringValue(HKCU, 'Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
|
||||||
|
Result := True;
|
||||||
|
end;
|
||||||
|
|||||||
@@ -1,19 +1,48 @@
|
|||||||
# Build complet : publish + Inno Setup
|
# Build complet : publish + Inno Setup
|
||||||
#
|
#
|
||||||
# Pré-requis :
|
# Pre-requis :
|
||||||
# - .NET 8 SDK
|
# - .NET 8 SDK
|
||||||
# - Inno Setup 6.x (https://jrsoftware.org/isdl.php)
|
# - Inno Setup 6.x (https://jrsoftware.org/isdl.php)
|
||||||
#
|
#
|
||||||
# Lancer depuis la racine du repo :
|
# Lancer depuis la racine du repo :
|
||||||
# powershell -ExecutionPolicy Bypass -File installer\build-installer.ps1
|
# powershell -ExecutionPolicy Bypass -File installer\build-installer.ps1
|
||||||
|
#
|
||||||
|
# NB : ce fichier est volontairement sans accents pour rester compatible
|
||||||
|
# PowerShell 5.1 (qui lit les .ps1 sans BOM en Windows-1252, pas UTF-8).
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[string]$ISCC = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
[string]$ISCC = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$root = Split-Path -Parent $PSScriptRoot
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
|
||||||
|
# Resolution de ISCC.exe : argument explicite > emplacements connus > PATH.
|
||||||
|
function Find-Iscc {
|
||||||
|
param([string]$Hint)
|
||||||
|
|
||||||
|
if ($Hint -and (Test-Path $Hint)) { return $Hint }
|
||||||
|
|
||||||
|
# Couvre les installs system-wide (Program Files) et user-mode
|
||||||
|
# (%LocalAppData%\Programs, emplacement par defaut quand winget installe sans admin).
|
||||||
|
$candidates = @(
|
||||||
|
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
|
||||||
|
"C:\Program Files\Inno Setup 6\ISCC.exe",
|
||||||
|
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe",
|
||||||
|
"C:\Program Files (x86)\Inno Setup 5\ISCC.exe",
|
||||||
|
"C:\Program Files\Inno Setup 5\ISCC.exe",
|
||||||
|
"$env:LOCALAPPDATA\Programs\Inno Setup 5\ISCC.exe"
|
||||||
|
)
|
||||||
|
foreach ($c in $candidates) {
|
||||||
|
if (Test-Path $c) { return $c }
|
||||||
|
}
|
||||||
|
|
||||||
|
$cmd = Get-Command iscc -ErrorAction SilentlyContinue
|
||||||
|
if ($cmd) { return $cmd.Source }
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "==> dotnet publish PSLauncher.App (Release single-file)" -ForegroundColor Cyan
|
Write-Host "==> dotnet publish PSLauncher.App (Release single-file)" -ForegroundColor Cyan
|
||||||
dotnet publish "$root\src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
|
dotnet publish "$root\src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
|
||||||
if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.App failed" }
|
if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.App failed" }
|
||||||
@@ -22,12 +51,26 @@ Write-Host "==> dotnet publish PSLauncher.Updater (Release single-file)" -Foregr
|
|||||||
dotnet publish "$root\src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo
|
dotnet publish "$root\src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo
|
||||||
if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.Updater failed" }
|
if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.Updater failed" }
|
||||||
|
|
||||||
if (-not (Test-Path $ISCC)) {
|
$resolved = Find-Iscc $ISCC
|
||||||
throw "ISCC.exe introuvable à $ISCC. Installe Inno Setup ou passe -ISCC <chemin>."
|
if (-not $resolved) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "ERREUR : Inno Setup (ISCC.exe) introuvable." -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Installe Inno Setup 6 depuis :" -ForegroundColor Yellow
|
||||||
|
Write-Host " https://jrsoftware.org/isdl.php" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Ou installation silencieuse via winget :" -ForegroundColor Yellow
|
||||||
|
Write-Host " winget install JRSoftware.InnoSetup" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Une fois installe, relance ce script. Si ISCC.exe est dans un emplacement" -ForegroundColor Yellow
|
||||||
|
Write-Host "non standard, passe le chemin via -ISCC :" -ForegroundColor Yellow
|
||||||
|
Write-Host " .\installer\build-installer.ps1 -ISCC 'D:\Tools\Inno\ISCC.exe'" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
throw "ISCC.exe introuvable. Inno Setup n'est pas installe sur cette machine."
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "==> Inno Setup compile" -ForegroundColor Cyan
|
Write-Host "==> Inno Setup compile (ISCC: $resolved)" -ForegroundColor Cyan
|
||||||
& $ISCC "$PSScriptRoot\PSLauncher.iss"
|
& $resolved "$PSScriptRoot\PSLauncher.iss"
|
||||||
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
|
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
BIN
installer/redists/MicrosoftEdgeWebview2Setup.exe
Normal file
BIN
installer/redists/MicrosoftEdgeWebview2Setup.exe
Normal file
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
RewriteBase /PS_Launcher/
|
RewriteBase /PS_Launcher/
|
||||||
|
|
||||||
|
# Forward du header Authorization vers PHP — Apache FastCGI le strippe par défaut
|
||||||
|
# sur OVH mutualisé. Sans ça, $_SERVER['HTTP_AUTHORIZATION'] est vide quand le
|
||||||
|
# client envoie "Authorization: Bearer ...".
|
||||||
|
RewriteCond %{HTTP:Authorization} ^(.+)$
|
||||||
|
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%1,L=0]
|
||||||
|
|
||||||
# HTTPS forcé (si le mutualisé OVH ne le force pas déjà)
|
# HTTPS forcé (si le mutualisé OVH ne le force pas déjà)
|
||||||
RewriteCond %{HTTPS} off
|
RewriteCond %{HTTPS} off
|
||||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||||
|
|||||||
1
server/admin/.gitkeep
Normal file
1
server/admin/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Placeholder — les vrais ZIPs (proserve-X.Y.Z.zip) sont uploadés en SFTP, pas en git.
|
||||||
BIN
server/admin/assets/asterion-logo.png
Normal file
BIN
server/admin/assets/asterion-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
@@ -130,3 +130,163 @@ details[open] { background: var(--bg-elevated); padding: 8px; border-radius: 4px
|
|||||||
|
|
||||||
a { color: var(--accent); }
|
a { color: var(--accent); }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Modal (<dialog>) — éditeur d'une entrée du manifest
|
||||||
|
Remplace les <details> inline qui débordaient horizontalement
|
||||||
|
============================================================ */
|
||||||
|
/* Styles visuels appliqués au dialog OUVERT ([open] est posé par
|
||||||
|
showModal() automatiquement). Sans le sélecteur [open], les règles
|
||||||
|
`display: flex` etc. écrasaient le `display: none` du user-agent
|
||||||
|
stylesheet → tous les dialogs apparaissaient inline au lieu d'être
|
||||||
|
cachés tant que pas explicitement ouverts. */
|
||||||
|
dialog.edit-modal[open] {
|
||||||
|
background: var(--bg-card); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 0; margin: auto;
|
||||||
|
width: min(640px, 92vw); max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
dialog.edit-modal::backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header {
|
||||||
|
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header h3 {
|
||||||
|
margin: 0; font-size: 15px; font-weight: 600;
|
||||||
|
flex: 1; color: var(--text);
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header .close-x {
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--text-secondary); cursor: pointer;
|
||||||
|
font-size: 20px; line-height: 1; padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
dialog.edit-modal > header .close-x:hover {
|
||||||
|
background: var(--bg-card); color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Onglets verticaux compacts en tête */
|
||||||
|
.modal-tabs {
|
||||||
|
display: flex; gap: 4px; padding: 8px 12px 0 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.modal-tabs button {
|
||||||
|
background: transparent; border: none;
|
||||||
|
color: var(--text-secondary); cursor: pointer;
|
||||||
|
font-size: 13px; font-weight: 500;
|
||||||
|
padding: 8px 14px; border-radius: 4px 4px 0 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
.modal-tabs button:hover {
|
||||||
|
color: var(--text); background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
.modal-tabs button.active {
|
||||||
|
color: var(--text); border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1; overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.modal-body > section[hidden] { display: none; }
|
||||||
|
.modal-body > section { display: block; }
|
||||||
|
.modal-body .field:last-child { margin-bottom: 0; }
|
||||||
|
.modal-body .hint {
|
||||||
|
color: var(--text-secondary); font-size: 11px;
|
||||||
|
margin: 4px 0 0 0; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.modal-body .warn {
|
||||||
|
color: #FBBF24; font-size: 11px;
|
||||||
|
margin: 4px 0 0 0; line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip-style note in the modal sections explaining a section purpose */
|
||||||
|
.section-intro {
|
||||||
|
background: var(--bg-input); border-left: 3px solid var(--accent);
|
||||||
|
padding: 10px 14px; border-radius: 4px;
|
||||||
|
font-size: 12px; color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Liste de checkboxes verticale propre (pour Channels) */
|
||||||
|
.checkbox-list {
|
||||||
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
|
}
|
||||||
|
.checkbox-list label {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px; border-radius: 4px;
|
||||||
|
font-size: 13px; color: var(--text); font-weight: normal;
|
||||||
|
cursor: pointer; margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.checkbox-list label:hover { background: var(--bg-elevated); }
|
||||||
|
.checkbox-list label code { flex: 0 0 auto; }
|
||||||
|
.checkbox-list label .muted { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Actions inline d'une row de version : harmonise les hauteurs et l'espacement */
|
||||||
|
.row-actions {
|
||||||
|
display: inline-flex; gap: 6px; align-items: center;
|
||||||
|
flex-wrap: nowrap; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.row-actions .btn { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Overlay blocant affiché pendant les opérations longues
|
||||||
|
(re-hash d'un ZIP de 13 Go = 3-5 min côté serveur OVH).
|
||||||
|
Visible tant que le browser n'a pas reçu la réponse POST,
|
||||||
|
disparaît naturellement au reload de page suivant.
|
||||||
|
============================================================ */
|
||||||
|
.busy-overlay {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.busy-overlay[hidden] { display: none; }
|
||||||
|
.busy-content {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 40px 56px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.busy-content h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: none; letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.busy-content p {
|
||||||
|
margin: 6px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.busy-spinner {
|
||||||
|
width: 56px; height: 56px;
|
||||||
|
border: 4px solid var(--bg-elevated);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: busy-spin 1s linear infinite;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
}
|
||||||
|
@keyframes busy-spin { to { transform: rotate(360deg); } }
|
||||||
|
|||||||
231
server/admin/channels.php
Normal file
231
server/admin/channels.php
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/lib/Auth.php';
|
||||||
|
require __DIR__ . '/lib/Layout.php';
|
||||||
|
require __DIR__ . '/lib/Channels.php';
|
||||||
|
|
||||||
|
use PSLauncher\Admin\Auth;
|
||||||
|
use PSLauncher\Admin\Layout;
|
||||||
|
use PSLauncher\Admin\Channels;
|
||||||
|
|
||||||
|
Auth::requireLogin();
|
||||||
|
$config = require __DIR__ . '/../api/config.php';
|
||||||
|
|
||||||
|
$manifestDir = dirname(__DIR__) . '/manifest';
|
||||||
|
$message = null; $messageType = 'success';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
Auth::checkCsrf();
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
try {
|
||||||
|
$registry = Channels::load($manifestDir);
|
||||||
|
|
||||||
|
if ($action === 'create') {
|
||||||
|
$rawName = (string)($_POST['name'] ?? '');
|
||||||
|
$name = Channels::normalizeName($rawName);
|
||||||
|
$label = trim((string)($_POST['label'] ?? '')) ?: $name;
|
||||||
|
$desc = trim((string)($_POST['description'] ?? '')) ?: null;
|
||||||
|
|
||||||
|
if (!Channels::isValidName($name)) {
|
||||||
|
throw new \RuntimeException("Nom invalide après normalisation : « {$rawName} » → « {$name} ». Saisis quelque chose comme « asterion-vr » ou « police ».");
|
||||||
|
}
|
||||||
|
if ($name === 'default') {
|
||||||
|
throw new \RuntimeException("Le channel « default » est implicite, on ne peut pas en créer un autre.");
|
||||||
|
}
|
||||||
|
foreach ($registry['channels'] as $c) {
|
||||||
|
if (($c['name'] ?? '') === $name) {
|
||||||
|
throw new \RuntimeException("Channel « {$name} » existe déjà.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = ['name' => $name, 'label' => $label];
|
||||||
|
if ($desc !== null) $entry['description'] = $desc;
|
||||||
|
$registry['channels'][] = $entry;
|
||||||
|
Channels::save($manifestDir, $registry);
|
||||||
|
$message = "Channel « {$name} » créé.";
|
||||||
|
if ($name !== $rawName) {
|
||||||
|
$message .= " (Nom normalisé depuis « {$rawName} ».)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elseif ($action === 'edit') {
|
||||||
|
$name = trim((string)($_POST['name'] ?? ''));
|
||||||
|
$label = trim((string)($_POST['label'] ?? '')) ?: $name;
|
||||||
|
$desc = trim((string)($_POST['description'] ?? '')) ?: null;
|
||||||
|
$found = false;
|
||||||
|
foreach ($registry['channels'] as &$c) {
|
||||||
|
if (($c['name'] ?? '') === $name) {
|
||||||
|
$c['label'] = $label;
|
||||||
|
if ($desc === null) unset($c['description']);
|
||||||
|
else $c['description'] = $desc;
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($c);
|
||||||
|
if (!$found) throw new \RuntimeException("Channel « {$name} » introuvable.");
|
||||||
|
Channels::save($manifestDir, $registry);
|
||||||
|
$message = "Channel « {$name} » mis à jour.";
|
||||||
|
}
|
||||||
|
|
||||||
|
elseif ($action === 'delete') {
|
||||||
|
$name = trim((string)($_POST['name'] ?? ''));
|
||||||
|
if ($name === 'default') throw new \RuntimeException("Le channel « default » ne peut pas être supprimé.");
|
||||||
|
|
||||||
|
// Sécurité : empêche la suppression si des versions ou licenses
|
||||||
|
// référencent encore ce channel — sinon ces refs deviennent silencieusement
|
||||||
|
// pendantes et l'admin oublie pourquoi tel client ne voit rien.
|
||||||
|
$usage = checkChannelUsage($manifestDir, $name);
|
||||||
|
if ($usage['versions'] > 0 || $usage['licenses'] > 0) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"Impossible de supprimer « {$name} » : "
|
||||||
|
. "{$usage['versions']} version(s) taggée(s) et {$usage['licenses']} license(s) attribuée(s) à ce channel. "
|
||||||
|
. "Détague d'abord les versions (page Versions) et change les licenses concernées (page Licenses)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$registry['channels'] = array_values(array_filter(
|
||||||
|
$registry['channels'],
|
||||||
|
fn($c) => ($c['name'] ?? '') !== $name
|
||||||
|
));
|
||||||
|
Channels::save($manifestDir, $registry);
|
||||||
|
$message = "Channel « {$name} » supprimé.";
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$message = $e->getMessage();
|
||||||
|
$messageType = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compte les versions et licenses qui pointent encore sur un channel.
|
||||||
|
* Sert de garde-fou avant suppression.
|
||||||
|
*/
|
||||||
|
function checkChannelUsage(string $manifestDir, string $channelName): array
|
||||||
|
{
|
||||||
|
$usage = ['versions' => 0, 'licenses' => 0];
|
||||||
|
|
||||||
|
$manifestPath = $manifestDir . '/versions.json';
|
||||||
|
if (is_file($manifestPath)) {
|
||||||
|
$m = json_decode(file_get_contents($manifestPath), true);
|
||||||
|
if (is_array($m) && isset($m['versions'])) {
|
||||||
|
foreach ($m['versions'] as $v) {
|
||||||
|
$channels = $v['channels'] ?? [];
|
||||||
|
if (in_array($channelName, $channels, true)) $usage['versions']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$config = require __DIR__ . '/../api/config.php';
|
||||||
|
$db = \PSLauncher\Db::get($config);
|
||||||
|
$stmt = $db->prepare('SELECT COUNT(*) FROM licenses WHERE channel = ?');
|
||||||
|
$stmt->execute([$channelName]);
|
||||||
|
$usage['licenses'] = (int)$stmt->fetchColumn();
|
||||||
|
} catch (\Throwable) { /* DB not reachable from this context, best effort */ }
|
||||||
|
|
||||||
|
return $usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../api/lib/Db.php';
|
||||||
|
|
||||||
|
$registry = Channels::load($manifestDir);
|
||||||
|
|
||||||
|
Layout::header('Channels', 'channels');
|
||||||
|
?>
|
||||||
|
<h1>Channels de distribution</h1>
|
||||||
|
|
||||||
|
<?php Layout::flash($message, $messageType); ?>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Comment ça marche</h2>
|
||||||
|
<ul class="muted">
|
||||||
|
<li><strong>default</strong> = channel public implicite. Toutes les versions taggées « default » (ou sans tag) sont visibles par tous les clients, peu importe leur license.</li>
|
||||||
|
<li>Les autres channels (<strong>asterion-vr</strong>, <strong>police</strong>, …) sont des canaux privés. Une version taggée uniquement sur un channel privé n'est vue QUE par les licenses attribuées à ce channel.</li>
|
||||||
|
<li>Une version peut être taggée sur plusieurs channels en même temps (multi-channel). Une license n'est attribuée qu'à un seul channel à la fois.</li>
|
||||||
|
<li>Sémantique additive : un client sur le channel « police » voit les versions « default » + les versions « police ». Pour rendre une version invisible au public, ne la tague PAS « default ».</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Créer un channel</h2>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="create">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col field">
|
||||||
|
<label>Nom (identifiant ASCII)</label>
|
||||||
|
<input type="text" name="name" required maxlength="80" placeholder="asterion-vr"
|
||||||
|
title="Sera normalisé en lowercase + tirets côté serveur (« ASTERION VR » → « asterion-vr »).">
|
||||||
|
</div>
|
||||||
|
<div class="col field">
|
||||||
|
<label>Label (affiché dans les UI)</label>
|
||||||
|
<input type="text" name="label" maxlength="120" placeholder="ASTERION VR (interne)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description (optionnel)</label>
|
||||||
|
<input type="text" name="description" maxlength="255" placeholder="Builds internes pour l'équipe ASTERION">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-success" type="submit">Créer</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Channels existants</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th style="text-align:right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($registry['channels'] as $c):
|
||||||
|
$isDefault = ($c['name'] ?? '') === 'default';
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code><?= htmlspecialchars($c['name'] ?? '') ?></code>
|
||||||
|
<?php if ($isDefault): ?>
|
||||||
|
<span class="badge badge-success" style="margin-left: 6px;" title="Channel implicite, immutable">implicite</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= htmlspecialchars($c['label'] ?? $c['name'] ?? '') ?></td>
|
||||||
|
<td class="muted"><?= htmlspecialchars($c['description'] ?? '') ?></td>
|
||||||
|
<td style="text-align:right; white-space: nowrap;">
|
||||||
|
<details style="display: inline-block; margin: 0 4px;">
|
||||||
|
<summary class="btn btn-secondary">Éditer</summary>
|
||||||
|
<form method="post" style="margin-top: 8px; min-width: 320px;">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="edit">
|
||||||
|
<input type="hidden" name="name" value="<?= htmlspecialchars($c['name'] ?? '') ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Label</label>
|
||||||
|
<input type="text" name="label" value="<?= htmlspecialchars($c['label'] ?? '') ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Description</label>
|
||||||
|
<input type="text" name="description" value="<?= htmlspecialchars($c['description'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<?php if (!$isDefault): ?>
|
||||||
|
<form method="post" style="display:inline" onsubmit="return confirm('Supprimer le channel <?= htmlspecialchars($c['name']) ?> ?\nLes versions et licenses qui le référencent doivent être détaguées d\'abord.')">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="name" value="<?= htmlspecialchars($c['name'] ?? '') ?>">
|
||||||
|
<button class="btn btn-danger" type="submit">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php Layout::footer();
|
||||||
199
server/admin/launcher.php
Normal file
199
server/admin/launcher.php
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require __DIR__ . '/lib/Auth.php';
|
||||||
|
require __DIR__ . '/lib/Layout.php';
|
||||||
|
|
||||||
|
use PSLauncher\Admin\Auth;
|
||||||
|
use PSLauncher\Admin\Layout;
|
||||||
|
|
||||||
|
Auth::requireLogin();
|
||||||
|
$config = require __DIR__ . '/../api/config.php';
|
||||||
|
|
||||||
|
$root = dirname(__DIR__);
|
||||||
|
$manifestPath = "$root/manifest/versions.json";
|
||||||
|
$buildsDir = "$root/builds";
|
||||||
|
|
||||||
|
$message = null; $messageType = 'success';
|
||||||
|
|
||||||
|
function loadManifest(string $path): array
|
||||||
|
{
|
||||||
|
if (!is_file($path)) return ['schemaVersion' => 1, 'versions' => []];
|
||||||
|
return json_decode(file_get_contents($path), true) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveManifest(string $path, array $manifest): void
|
||||||
|
{
|
||||||
|
// usort attend une référence — on ne peut pas lui passer `?? []` en PHP 8.
|
||||||
|
if (isset($manifest['versions']) && is_array($manifest['versions'])) {
|
||||||
|
usort($manifest['versions'], fn($a, $b) => version_compare($b['version'], $a['version']));
|
||||||
|
}
|
||||||
|
file_put_contents(
|
||||||
|
$path,
|
||||||
|
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
Auth::checkCsrf();
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
$manifest = loadManifest($manifestPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($action === 'set_launcher') {
|
||||||
|
$lver = trim($_POST['launcher_version'] ?? '');
|
||||||
|
$lmin = trim($_POST['launcher_min_required'] ?? '');
|
||||||
|
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||||
|
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lver)) {
|
||||||
|
throw new Exception('Numéro de version launcher invalide (X.Y.Z ou X.Y.Z.B attendu).');
|
||||||
|
}
|
||||||
|
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lmin)) {
|
||||||
|
throw new Exception('Numéro de version "minRequired" invalide.');
|
||||||
|
}
|
||||||
|
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||||||
|
$manifest['launcher'] = [
|
||||||
|
'version' => $lver,
|
||||||
|
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
|
||||||
|
'download' => [
|
||||||
|
'url' => "{$base}/builds/launcher/PS_Launcher-{$lver}.exe",
|
||||||
|
'sizeBytes' => 0,
|
||||||
|
'sha256' => 'REPLACE_AFTER_BUILD',
|
||||||
|
],
|
||||||
|
'releaseNotesUrl' => null,
|
||||||
|
];
|
||||||
|
saveManifest($manifestPath, $manifest);
|
||||||
|
$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') {
|
||||||
|
unset($manifest['launcher']);
|
||||||
|
saveManifest($manifestPath, $manifest);
|
||||||
|
$message = "Section launcher retirée du manifest (auto-update désactivé).";
|
||||||
|
}
|
||||||
|
elseif ($action === 'sync_launcher') {
|
||||||
|
require_once "$root/tools/SignManifest.php";
|
||||||
|
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||||||
|
$force = !empty($_POST['force']);
|
||||||
|
$result = $signer->run('launcher', $force);
|
||||||
|
$forceLabel = $force ? ' [FORCE: cache ignoré]' : '';
|
||||||
|
$message = "Sortie du script (scope: launcher{$forceLabel}) :\n" . implode("\n", $result['log']);
|
||||||
|
if (!$result['ok']) $messageType = 'error';
|
||||||
|
}
|
||||||
|
// (Le settings lock est maintenant par-license, géré dans licenses.php — voir migration 003.)
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$message = $e->getMessage();
|
||||||
|
$messageType = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = loadManifest($manifestPath);
|
||||||
|
$launcher = $manifest['launcher'] ?? null;
|
||||||
|
$launcherFiles = is_dir("$buildsDir/launcher")
|
||||||
|
? array_map('basename', glob("$buildsDir/launcher/*.exe") ?: [])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$launcherZipPresent = false;
|
||||||
|
$launcherHashed = false;
|
||||||
|
if ($launcher) {
|
||||||
|
$expectedFile = basename(parse_url($launcher['download']['url'] ?? '', PHP_URL_PATH) ?? '');
|
||||||
|
$launcherZipPresent = in_array($expectedFile, $launcherFiles, true);
|
||||||
|
$launcherHashed = !empty($launcher['download']['sha256'])
|
||||||
|
&& !str_starts_with($launcher['download']['sha256'], 'REPLACE');
|
||||||
|
}
|
||||||
|
|
||||||
|
Layout::header('Launcher', 'launcher');
|
||||||
|
?>
|
||||||
|
<h1>Launcher — auto-update</h1>
|
||||||
|
|
||||||
|
<?php Layout::flash($message, $messageType); ?>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>État actuel</h2>
|
||||||
|
<?php if ($launcher): ?>
|
||||||
|
<p>
|
||||||
|
Version annoncée : <strong>v<?= htmlspecialchars($launcher['version']) ?></strong>
|
||||||
|
• minRequired : <code><?= htmlspecialchars($launcher['minRequired'] ?? '—') ?></code>
|
||||||
|
• Exe : <?= $launcherZipPresent
|
||||||
|
? "<span class='badge badge-success'>présent</span>"
|
||||||
|
: "<span class='badge badge-warning'>absent</span>" ?>
|
||||||
|
• Hash : <?= $launcherHashed
|
||||||
|
? "<span class='badge badge-success'>OK</span>"
|
||||||
|
: "<span class='badge badge-warning'>à calculer</span>" ?>
|
||||||
|
• Signature manifest : <?= empty($manifest['signature'])
|
||||||
|
? "<span class='badge badge-warning'>NON SIGNÉ</span>"
|
||||||
|
: "<span class='badge badge-success'>SIGNÉ Ed25519</span>" ?>
|
||||||
|
</p>
|
||||||
|
<p class="muted">URL attendue de l'exe : <code><?= htmlspecialchars($launcher['download']['url']) ?></code></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="muted">Aucune version du launcher déclarée — l'auto-update est désactivé. Configure-la ci-dessous pour l'activer.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Workflow d'une nouvelle version du launcher</h2>
|
||||||
|
<ol class="muted">
|
||||||
|
<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>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>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>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Définir / mettre à jour</h2>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_launcher">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col field">
|
||||||
|
<label>Version launcher (X.Y.Z ou X.Y.Z.B)</label>
|
||||||
|
<input type="text" name="launcher_version" placeholder="0.6.0"
|
||||||
|
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+(\.\d+)?">
|
||||||
|
</div>
|
||||||
|
<div class="col field">
|
||||||
|
<label>Version minimale requise (X.Y.Z ou X.Y.Z.B)</label>
|
||||||
|
<input type="text" name="launcher_min_required" placeholder="0.5.0"
|
||||||
|
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+(\.\d+)?">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-success" type="submit">Définir</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" style="margin-top: 16px;">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="sync_launcher">
|
||||||
|
<button class="btn btn-primary" type="submit">🔁 Hasher le launcher + signer</button>
|
||||||
|
<label style="margin-left:12px;font-size:12px;color:#888" title="Recalcule le SHA-256 même si l'exe n'a pas changé.">
|
||||||
|
<input type="checkbox" name="force" value="1"> Force re-hash
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($launcher): ?>
|
||||||
|
<form method="post" style="margin-top: 16px;"
|
||||||
|
onsubmit="return confirm('Retirer la section launcher du manifest ? L\'auto-update sera désactivé.')">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="remove_launcher">
|
||||||
|
<button class="btn btn-danger" type="submit">Retirer la section launcher</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Exes présents dans builds/launcher/</h2>
|
||||||
|
<?php if (empty($launcherFiles)): ?>
|
||||||
|
<p class="muted">Aucun exe pour l'instant. Upload les binaires via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Fichier</th><th>Taille</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($launcherFiles as $f): ?>
|
||||||
|
<tr>
|
||||||
|
<td><code><?= htmlspecialchars($f) ?></code></td>
|
||||||
|
<td class="muted"><?= Layout::formatBytes(filesize("$buildsDir/launcher/$f")) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php Layout::footer();
|
||||||
96
server/admin/lib/Channels.php
Normal file
96
server/admin/lib/Channels.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PSLauncher\Admin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registre des channels de distribution. Stocké dans manifest/channels.json
|
||||||
|
* pour rester portable / pas dépendre de la DB pour cette config simple.
|
||||||
|
*
|
||||||
|
* Une entrée a 3 champs :
|
||||||
|
* - name : identifiant ASCII utilisé en code partout (whitelist a-z0-9_-)
|
||||||
|
* - label : libellé human-friendly affiché dans les UI admin
|
||||||
|
* - description : contexte libre pour l'admin (optionnel)
|
||||||
|
*
|
||||||
|
* Le channel "default" est implicite et toujours présent : il représente les
|
||||||
|
* versions publiques (vues par toutes les licenses, peu importe leur tag). Les
|
||||||
|
* versions sans tag explicite sont implicitement dans "default".
|
||||||
|
*
|
||||||
|
* Les licenses pointent vers UN channel — elles voient les versions taggées
|
||||||
|
* avec ce channel ET celles taggées "default" (sémantique additive).
|
||||||
|
*/
|
||||||
|
final class Channels
|
||||||
|
{
|
||||||
|
/** @return array{channels: array<int, array{name:string,label:string,description?:string}>} */
|
||||||
|
public static function load(string $manifestDir): array
|
||||||
|
{
|
||||||
|
$path = $manifestDir . '/channels.json';
|
||||||
|
$base = ['channels' => []];
|
||||||
|
if (is_file($path)) {
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
$json = json_decode((string)$raw, true);
|
||||||
|
if (is_array($json) && isset($json['channels']) && is_array($json['channels'])) {
|
||||||
|
$base['channels'] = $json['channels'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garantit que "default" est toujours présent en tête (immutable depuis la UI).
|
||||||
|
$hasDefault = false;
|
||||||
|
foreach ($base['channels'] as $c) {
|
||||||
|
if (($c['name'] ?? '') === 'default') { $hasDefault = true; break; }
|
||||||
|
}
|
||||||
|
if (!$hasDefault) {
|
||||||
|
array_unshift($base['channels'], [
|
||||||
|
'name' => 'default',
|
||||||
|
'label' => 'Public',
|
||||||
|
'description' => 'Versions publiques visibles par tous les clients',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function save(string $manifestDir, array $registry): void
|
||||||
|
{
|
||||||
|
$path = $manifestDir . '/channels.json';
|
||||||
|
if (!is_dir($manifestDir)) mkdir($manifestDir, 0755, true);
|
||||||
|
file_put_contents(
|
||||||
|
$path,
|
||||||
|
json_encode($registry, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne true si <code>$name</code> est un identifiant de channel valide :
|
||||||
|
* lowercase, chiffres, tirets et underscores, 1 à 64 caractères.
|
||||||
|
*/
|
||||||
|
public static function isValidName(string $name): bool
|
||||||
|
{
|
||||||
|
return (bool)preg_match('/^[a-z0-9_-]{1,64}$/', $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise un nom saisi par l'admin : lowercase, espaces → tirets, retire
|
||||||
|
* les chars hors whitelist, compresse les tirets multiples, trim.
|
||||||
|
* Identique à ps_normalize_channel() dans versions.php — centralisé ici
|
||||||
|
* pour les futurs ajouts.
|
||||||
|
*/
|
||||||
|
public static function normalizeName(string $raw): string
|
||||||
|
{
|
||||||
|
$s = strtolower(trim($raw));
|
||||||
|
$s = (string)preg_replace('/[\s\x{2010}-\x{2015}]+/u', '-', $s);
|
||||||
|
$s = (string)preg_replace('/[^a-z0-9_-]/', '', $s);
|
||||||
|
$s = (string)preg_replace('/-+/', '-', $s);
|
||||||
|
$s = trim($s, '-_');
|
||||||
|
return substr($s, 0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string> Liste des noms de channel pour les dropdowns.
|
||||||
|
*/
|
||||||
|
public static function listNames(string $manifestDir): array
|
||||||
|
{
|
||||||
|
$reg = self::load($manifestDir);
|
||||||
|
return array_map(fn($c) => (string)($c['name'] ?? ''), $reg['channels']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,8 @@ HTML;
|
|||||||
'dashboard' => ['index.php', 'Dashboard'],
|
'dashboard' => ['index.php', 'Dashboard'],
|
||||||
'licenses' => ['licenses.php', 'Licenses'],
|
'licenses' => ['licenses.php', 'Licenses'],
|
||||||
'versions' => ['versions.php', 'Versions'],
|
'versions' => ['versions.php', 'Versions'],
|
||||||
|
'channels' => ['channels.php', 'Channels'],
|
||||||
|
'launcher' => ['launcher.php', 'Launcher'],
|
||||||
'audit' => ['audit.php', 'Audit'],
|
'audit' => ['audit.php', 'Audit'],
|
||||||
];
|
];
|
||||||
$links = '';
|
$links = '';
|
||||||
|
|||||||
201
server/admin/lib/Mailer.php
Normal file
201
server/admin/lib/Mailer.php
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace PSLauncher\Admin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Petit helper d'envoi d'emails de notification (release announce).
|
||||||
|
*
|
||||||
|
* Stratégie : on utilise <code>mail()</code> natif PHP, qui sur OVH mutualisé
|
||||||
|
* passe par le relais SMTP local et marche out-of-the-box sans config
|
||||||
|
* additionnelle. Pas de dépendance composer (PHPMailer, etc.) volontairement
|
||||||
|
* — on a besoin de notifications opportunistes, pas de delivery garantie.
|
||||||
|
*
|
||||||
|
* Pour passer à PHPMailer / SMTP externe plus tard, la surface API reste la
|
||||||
|
* même : on remplace juste <see cref="sendInternal"/> par un appel
|
||||||
|
* <code>$mailer->send()</code>.
|
||||||
|
*/
|
||||||
|
final class Mailer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Parse un blob texte saisi par l'admin (CSV, ligne par ligne, mélange…)
|
||||||
|
* en liste d'emails normalisés et validés. Tout token qui ne matche pas
|
||||||
|
* la regex email est rejeté SILENCIEUSEMENT (l'admin n'a pas besoin d'un
|
||||||
|
* popup pour un copy/paste avec une virgule en trop).
|
||||||
|
*
|
||||||
|
* @return list<string> emails uniques lowercase
|
||||||
|
*/
|
||||||
|
public static function parseEmails(?string $raw): array
|
||||||
|
{
|
||||||
|
if ($raw === null || trim($raw) === '') return [];
|
||||||
|
// Split sur tout séparateur courant : virgule, point-virgule, espace,
|
||||||
|
// newline. Permet à l'admin de coller un copy/paste depuis Outlook,
|
||||||
|
// Excel, ou ligne par ligne — peu importe.
|
||||||
|
$tokens = preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
|
$out = [];
|
||||||
|
foreach ($tokens as $t) {
|
||||||
|
$t = strtolower(trim($t));
|
||||||
|
if ($t === '') continue;
|
||||||
|
if (!filter_var($t, FILTER_VALIDATE_EMAIL)) continue;
|
||||||
|
if (!in_array($t, $out, true)) $out[] = $t;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverse : prend un tableau d'emails et le formate pour stockage DB
|
||||||
|
* (séparé par virgules). NULL si la liste est vide pour économiser
|
||||||
|
* une ligne en DB sur les licenses sans contacts.
|
||||||
|
*/
|
||||||
|
public static function joinForStorage(array $emails): ?string
|
||||||
|
{
|
||||||
|
$emails = array_values(array_filter(
|
||||||
|
array_map('trim', $emails),
|
||||||
|
fn($e) => $e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)
|
||||||
|
));
|
||||||
|
return empty($emails) ? null : implode(', ', $emails);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un email HTML (avec partie texte alternative pour les MUA legacy)
|
||||||
|
* à un destinataire. Best-effort : retourne true en cas de succès apparent
|
||||||
|
* du sink mail(), false si rejet immédiat. Erreurs de delivery côté SMTP
|
||||||
|
* relais (bounce, deferred) ne sont PAS détectées ici — on log juste le
|
||||||
|
* résultat brut.
|
||||||
|
*
|
||||||
|
* Si <paramref name="$inlineImages"/> est non vide, on construit une structure
|
||||||
|
* MIME <c>multipart/related</c> (CID embed) au lieu de <c>multipart/alternative</c>.
|
||||||
|
* Les images sont attachées comme pièces jointes inline, référencées dans
|
||||||
|
* le HTML via <c><img src="cid:..."></c>. Avantage vs hotlink HTTP :
|
||||||
|
* Outlook (et MUA généralement) affiche l'image SANS demander de permission
|
||||||
|
* (vs hotlink bloqué par défaut pour anti-tracking pixel).
|
||||||
|
*
|
||||||
|
* @param array $notifConfig bloc $config['notifications'] (from_address, from_name, reply_to)
|
||||||
|
* @param string $to destinataire UNIQUE (pour multi-destinataires, appeler en boucle)
|
||||||
|
* @param string $subject sujet en clair (sera Q-encoded UTF-8 automatiquement)
|
||||||
|
* @param string $bodyHtml contenu HTML (multipart alt avec text auto-généré)
|
||||||
|
* @param array $inlineImages liste d'images à embed inline. Chaque entrée :
|
||||||
|
* ['cid' => 'logo', 'path' => '/abs/path/file.png', 'type' => 'image/png']
|
||||||
|
*/
|
||||||
|
public static function send(array $notifConfig, string $to, string $subject, string $bodyHtml, array $inlineImages = []): bool
|
||||||
|
{
|
||||||
|
$from = $notifConfig['from_address'] ?? 'no-reply@localhost';
|
||||||
|
$fromName = $notifConfig['from_name'] ?? 'PROSERVE Launcher';
|
||||||
|
$replyTo = $notifConfig['reply_to'] ?? '';
|
||||||
|
|
||||||
|
// Filtre les inline images : ne garde que celles dont le fichier existe
|
||||||
|
// ET est lisible. Si une image manque, on log silencieusement et on
|
||||||
|
// continue avec les autres — l'email part de toute façon.
|
||||||
|
$validImages = [];
|
||||||
|
foreach ($inlineImages as $img) {
|
||||||
|
$p = $img['path'] ?? '';
|
||||||
|
if ($p === '' || !is_file($p) || !is_readable($p)) continue;
|
||||||
|
$validImages[] = $img;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boundaries uniques. Si on a des images, on a 2 niveaux d'imbrication :
|
||||||
|
// multipart/related contenant multipart/alternative + image(s).
|
||||||
|
$boundaryAlt = '=_PSLauncherAlt_' . bin2hex(random_bytes(8));
|
||||||
|
$boundaryRel = '=_PSLauncherRel_' . bin2hex(random_bytes(8));
|
||||||
|
|
||||||
|
// Headers RFC 5322 + MIME. Pas de "Bcc:" — on appelle send() par destinataire
|
||||||
|
// pour avoir des "Per-user emails" et faciliter le debugging.
|
||||||
|
$contentType = empty($validImages)
|
||||||
|
? "multipart/alternative; boundary=\"{$boundaryAlt}\""
|
||||||
|
: "multipart/related; type=\"multipart/alternative\"; boundary=\"{$boundaryRel}\"";
|
||||||
|
$headers = [
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
"Content-Type: {$contentType}",
|
||||||
|
'From: ' . self::formatAddress($fromName, $from),
|
||||||
|
'X-Mailer: PSLauncher-Admin',
|
||||||
|
'X-Auto-Response-Suppress: All', // mute autoresponders OOO
|
||||||
|
];
|
||||||
|
if ($replyTo !== '' && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$headers[] = 'Reply-To: ' . $replyTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Construction du body ===
|
||||||
|
$bodyText = self::htmlToPlainText($bodyHtml);
|
||||||
|
|
||||||
|
// Bloc multipart/alternative (text + html)
|
||||||
|
$altPart = "--{$boundaryAlt}\r\n";
|
||||||
|
$altPart .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
|
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||||
|
$altPart .= $bodyText . "\r\n\r\n";
|
||||||
|
$altPart .= "--{$boundaryAlt}\r\n";
|
||||||
|
$altPart .= "Content-Type: text/html; charset=UTF-8\r\n";
|
||||||
|
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
|
||||||
|
$altPart .= $bodyHtml . "\r\n\r\n";
|
||||||
|
$altPart .= "--{$boundaryAlt}--\r\n";
|
||||||
|
|
||||||
|
if (empty($validImages)) {
|
||||||
|
// Pas d'images → email simple, juste l'alternative
|
||||||
|
$body = $altPart;
|
||||||
|
} else {
|
||||||
|
// Avec images → multipart/related wrappant l'alt + chaque image
|
||||||
|
$body = "--{$boundaryRel}\r\n";
|
||||||
|
$body .= "Content-Type: multipart/alternative; boundary=\"{$boundaryAlt}\"\r\n\r\n";
|
||||||
|
$body .= $altPart;
|
||||||
|
foreach ($validImages as $img) {
|
||||||
|
$cid = $img['cid'] ?? bin2hex(random_bytes(6));
|
||||||
|
$type = $img['type'] ?? 'application/octet-stream';
|
||||||
|
$path = $img['path'];
|
||||||
|
$name = basename($path);
|
||||||
|
$data = @file_get_contents($path);
|
||||||
|
if ($data === false) continue;
|
||||||
|
$b64 = chunk_split(base64_encode($data), 76, "\r\n");
|
||||||
|
$body .= "\r\n--{$boundaryRel}\r\n";
|
||||||
|
$body .= "Content-Type: {$type}; name=\"{$name}\"\r\n";
|
||||||
|
$body .= "Content-Transfer-Encoding: base64\r\n";
|
||||||
|
$body .= "Content-ID: <{$cid}>\r\n";
|
||||||
|
$body .= "Content-Disposition: inline; filename=\"{$name}\"\r\n\r\n";
|
||||||
|
$body .= $b64;
|
||||||
|
}
|
||||||
|
$body .= "\r\n--{$boundaryRel}--\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectEncoded = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return @mail($to, $subjectEncoded, $body, implode("\r\n", $headers));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit un HTML simple en plain text pour la partie text/plain du
|
||||||
|
* multipart. Pas une vraie conversion (ce n'est pas html2text), mais
|
||||||
|
* suffisant pour nos templates qui sont quasi-text avec quelques tags.
|
||||||
|
*/
|
||||||
|
private static function htmlToPlainText(string $html): string
|
||||||
|
{
|
||||||
|
// Convert <br> et <p> en sauts de ligne avant strip_tags
|
||||||
|
$s = preg_replace('#<br\s*/?>#i', "\n", $html) ?? $html;
|
||||||
|
$s = preg_replace('#</p>\s*<p[^>]*>#i', "\n\n", $s) ?? $s;
|
||||||
|
$s = preg_replace('#<a\s[^>]*href="([^"]+)"[^>]*>([^<]+)</a>#i', '$2 ($1)', $s) ?? $s;
|
||||||
|
$s = strip_tags($s);
|
||||||
|
$s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
return trim($s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Pretty Name <email@x>" si name non vide, sinon "email@x" brut.
|
||||||
|
* Échappe les chars spéciaux conformément à RFC 5322.
|
||||||
|
*/
|
||||||
|
private static function formatAddress(string $name, string $email): string
|
||||||
|
{
|
||||||
|
$name = trim($name);
|
||||||
|
if ($name === '') return $email;
|
||||||
|
// Encode le name en MIME si pas pur ASCII
|
||||||
|
if (preg_match('/[\x80-\xFF]/', $name)) {
|
||||||
|
$name = '=?UTF-8?B?' . base64_encode($name) . '?=';
|
||||||
|
} else {
|
||||||
|
// Quote si chars spéciaux RFC : "()<>@,;:\\\"/.\\[\\]"
|
||||||
|
if (preg_match('/[()<>@,;:\\\"\/\[\]]/', $name)) {
|
||||||
|
$name = '"' . str_replace('"', '\\"', $name) . '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "{$name} <{$email}>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,14 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
require __DIR__ . '/lib/Auth.php';
|
require __DIR__ . '/lib/Auth.php';
|
||||||
require __DIR__ . '/lib/Layout.php';
|
require __DIR__ . '/lib/Layout.php';
|
||||||
|
require __DIR__ . '/lib/Channels.php';
|
||||||
|
require __DIR__ . '/lib/Mailer.php';
|
||||||
require __DIR__ . '/../api/lib/Db.php';
|
require __DIR__ . '/../api/lib/Db.php';
|
||||||
|
|
||||||
use PSLauncher\Admin\Auth;
|
use PSLauncher\Admin\Auth;
|
||||||
use PSLauncher\Admin\Layout;
|
use PSLauncher\Admin\Layout;
|
||||||
|
use PSLauncher\Admin\Channels;
|
||||||
|
use PSLauncher\Admin\Mailer;
|
||||||
use PSLauncher\Db;
|
use PSLauncher\Db;
|
||||||
|
|
||||||
Auth::requireLogin();
|
Auth::requireLogin();
|
||||||
@@ -27,6 +31,25 @@ function generateLicenseKey(): string
|
|||||||
return 'PRSRV-' . implode('-', $groups);
|
return 'PRSRV-' . implode('-', $groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste les channels disponibles depuis le registre channels.json. Le channel
|
||||||
|
* « default » apparaît avec une clé vide (= NULL en DB → comportement legacy
|
||||||
|
* « pas de tag, voit que default »).
|
||||||
|
*/
|
||||||
|
function listAvailableChannels(): array
|
||||||
|
{
|
||||||
|
$manifestDir = dirname(__DIR__) . '/manifest';
|
||||||
|
$registry = Channels::load($manifestDir);
|
||||||
|
$out = ['' => '(aucun — voit uniquement « default »)'];
|
||||||
|
foreach ($registry['channels'] as $c) {
|
||||||
|
$name = (string)($c['name'] ?? '');
|
||||||
|
if ($name === '' || $name === 'default') continue; // default est l'option implicite (clé vide)
|
||||||
|
$label = $c['label'] ?? $name;
|
||||||
|
$out[$name] = "{$name} — {$label}";
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
Auth::checkCsrf();
|
Auth::checkCsrf();
|
||||||
$action = $_POST['action'] ?? '';
|
$action = $_POST['action'] ?? '';
|
||||||
@@ -37,6 +60,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$until = trim($_POST['until'] ?? '');
|
$until = trim($_POST['until'] ?? '');
|
||||||
$maxMachines = max(1, (int)($_POST['max_machines'] ?? 1));
|
$maxMachines = max(1, (int)($_POST['max_machines'] ?? 1));
|
||||||
$notes = trim($_POST['notes'] ?? '') ?: null;
|
$notes = trim($_POST['notes'] ?? '') ?: null;
|
||||||
|
// Channel = '' ↦ NULL en DB (default manifest). Whitelist regex anti-injection.
|
||||||
|
$channel = trim((string)($_POST['channel'] ?? ''));
|
||||||
|
if ($channel === '') {
|
||||||
|
$channel = null;
|
||||||
|
} elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
|
||||||
|
throw new Exception('Channel invalide : seules les lettres minuscules, chiffres, _ et - sont autorisés (max 64 caractères).');
|
||||||
|
}
|
||||||
|
$canSeeBetas = isset($_POST['can_see_betas']) ? 1 : 0;
|
||||||
|
|
||||||
if ($owner === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) {
|
if ($owner === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) {
|
||||||
throw new Exception('Nom du client et date d\'expiration sont requis (date au format YYYY-MM-DD).');
|
throw new Exception('Nom du client et date d\'expiration sont requis (date au format YYYY-MM-DD).');
|
||||||
@@ -45,8 +76,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
for ($attempts = 0; $attempts < 5; $attempts++) {
|
for ($attempts = 0; $attempts < 5; $attempts++) {
|
||||||
$newKey = generateLicenseKey();
|
$newKey = generateLicenseKey();
|
||||||
try {
|
try {
|
||||||
$db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, notes) VALUES (?, ?, NOW(), ?, ?, ?)')
|
$db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, channel, can_see_betas, notes) VALUES (?, ?, NOW(), ?, ?, ?, ?, ?)')
|
||||||
->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $notes]);
|
->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $channel, $canSeeBetas, $notes]);
|
||||||
$message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée.";
|
$message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée.";
|
||||||
break;
|
break;
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
@@ -75,10 +106,116 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
->execute([$newUntil . ' 23:59:59', $id]);
|
->execute([$newUntil . ' 23:59:59', $id]);
|
||||||
$message = "License #{$id} prolongée jusqu'au {$newUntil}.";
|
$message = "License #{$id} prolongée jusqu'au {$newUntil}.";
|
||||||
}
|
}
|
||||||
|
elseif ($action === 'set_channel') {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$channel = trim((string)($_POST['channel'] ?? ''));
|
||||||
|
if ($channel === '') {
|
||||||
|
$channel = null; // default manifest
|
||||||
|
} elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
|
||||||
|
throw new Exception('Channel invalide.');
|
||||||
|
}
|
||||||
|
$db->prepare('UPDATE licenses SET channel = ? WHERE id = ?')->execute([$channel, $id]);
|
||||||
|
$label = $channel ?? 'default';
|
||||||
|
$message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
|
||||||
|
}
|
||||||
|
elseif ($action === 'toggle_betas') {
|
||||||
|
// Renommé semantically (toggle → set explicit) sans casser le nom
|
||||||
|
// de l'action POST pour préserver les éventuels liens externes
|
||||||
|
// existants. La modal envoie maintenant `enabled=1` si checkbox
|
||||||
|
// cochée, sinon le param est absent (= 0). Plus de flip aveugle.
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$enabled = isset($_POST['enabled']) ? 1 : 0;
|
||||||
|
$db->prepare('UPDATE licenses SET can_see_betas = ? WHERE id = ?')->execute([$enabled, $id]);
|
||||||
|
$message = $enabled
|
||||||
|
? "License #{$id} : accès aux versions BÊTA activé."
|
||||||
|
: "License #{$id} : accès aux versions BÊTA désactivé.";
|
||||||
|
}
|
||||||
|
elseif ($action === 'set_max_machines') {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$newMax = (int)($_POST['max_machines'] ?? 0);
|
||||||
|
if ($id <= 0 || $newMax < 1 || $newMax > 100) {
|
||||||
|
throw new Exception('Nombre de machines invalide (1–100).');
|
||||||
|
}
|
||||||
|
// Refus de descendre sous le nombre de machines déjà activées —
|
||||||
|
// sinon état incohérent (machines_count > max_machines). L'admin doit
|
||||||
|
// d'abord libérer des slots via "Libérer" individuel ou "Libérer toutes".
|
||||||
|
$stmt = $db->prepare('SELECT COUNT(*) FROM license_machines WHERE license_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$current = (int)$stmt->fetchColumn();
|
||||||
|
if ($newMax < $current) {
|
||||||
|
throw new Exception("Impossible : {$current} machines sont déjà actives. Libère des slots d'abord ou choisis ≥ {$current}.");
|
||||||
|
}
|
||||||
|
$db->prepare('UPDATE licenses SET max_machines = ? WHERE id = ?')->execute([$newMax, $id]);
|
||||||
|
$message = "License #{$id} : limite passée à {$newMax} machine(s).";
|
||||||
|
}
|
||||||
|
elseif ($action === 'set_settings_lock_password') {
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$pwd = (string)($_POST['settings_lock_password'] ?? '');
|
||||||
|
if ($id <= 0) {
|
||||||
|
throw new Exception('License id invalide.');
|
||||||
|
}
|
||||||
|
if ($pwd === '') {
|
||||||
|
// Vide = retire le verrouillage pour cette license
|
||||||
|
$db->prepare('UPDATE licenses SET settings_lock_password_hash = NULL WHERE id = ?')->execute([$id]);
|
||||||
|
$message = "License #{$id} : verrouillage des paramètres avancés retiré.";
|
||||||
|
} else {
|
||||||
|
if (strlen($pwd) < 4) {
|
||||||
|
throw new Exception('Mot de passe trop court (minimum 4 caractères).');
|
||||||
|
}
|
||||||
|
// SHA-256 hex lowercase, format compatible avec SettingsLockService côté launcher.
|
||||||
|
$hash = hash('sha256', $pwd);
|
||||||
|
$db->prepare('UPDATE licenses SET settings_lock_password_hash = ? WHERE id = ?')->execute([$hash, $id]);
|
||||||
|
$message = "License #{$id} : mot de passe des paramètres avancés mis à jour.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif ($action === 'set_contact_emails') {
|
||||||
|
// Met à jour la liste des emails de contact + la langue préférée
|
||||||
|
// pour les notifications de nouvelles versions. L'admin saisit en
|
||||||
|
// texte libre (1 par ligne, virgules, point-virgules — peu importe),
|
||||||
|
// on parse + valide via FILTER_VALIDATE_EMAIL et on stocke en CSV.
|
||||||
|
// Vide / aucun email valide → on stocke NULL (= pas de notif possible
|
||||||
|
// pour cette license, juste skip silencieux à l'envoi).
|
||||||
|
//
|
||||||
|
// Language : whitelist stricte fr/en/zh/th/ar (= ce que le launcher
|
||||||
|
// supporte côté Strings.cs). NULL ou inconnu → fallback English.
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$raw = (string)($_POST['contact_emails'] ?? '');
|
||||||
|
$lang = trim((string)($_POST['language'] ?? ''));
|
||||||
|
if ($id <= 0) {
|
||||||
|
throw new Exception('License id invalide.');
|
||||||
|
}
|
||||||
|
$allowedLangs = ['fr', 'en', 'es', 'de', 'zh', 'th', 'ar'];
|
||||||
|
if ($lang !== '' && !in_array($lang, $allowedLangs, true)) {
|
||||||
|
throw new Exception("Code langue invalide : '{$lang}'. Attendu : " . implode(', ', $allowedLangs) . ' ou vide.');
|
||||||
|
}
|
||||||
|
$parsed = Mailer::parseEmails($raw);
|
||||||
|
$stored = Mailer::joinForStorage($parsed);
|
||||||
|
$db->prepare('UPDATE licenses SET contact_emails = ?, language = ? WHERE id = ?')
|
||||||
|
->execute([$stored, $lang !== '' ? $lang : null, $id]);
|
||||||
|
$count = count($parsed);
|
||||||
|
$langDisplay = $lang !== '' ? $lang : 'en (défaut)';
|
||||||
|
$message = $count === 0
|
||||||
|
? "License #{$id} : contacts de notification supprimés. Langue email : {$langDisplay}."
|
||||||
|
: "License #{$id} : {$count} contact(s) email enregistré(s), langue email : {$langDisplay}.";
|
||||||
|
}
|
||||||
elseif ($action === 'reset_machines') {
|
elseif ($action === 'reset_machines') {
|
||||||
$id = (int)($_POST['id'] ?? 0);
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
|
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
|
||||||
$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();
|
||||||
@@ -86,6 +223,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$availableChannels = listAvailableChannels();
|
||||||
|
|
||||||
$licenses = $db->query(
|
$licenses = $db->query(
|
||||||
'SELECT l.*,
|
'SELECT l.*,
|
||||||
(SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count
|
(SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count
|
||||||
@@ -93,6 +232,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>
|
||||||
@@ -126,6 +277,23 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<input type="number" name="max_machines" value="1" min="1" max="100">
|
<input type="number" name="max_machines" value="1" min="1" max="100">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col field">
|
||||||
|
<label>Channel <span class="muted" style="font-weight: normal; font-size: 11px;">(quel manifest sert le client ?)</span></label>
|
||||||
|
<select name="channel">
|
||||||
|
<?php foreach ($availableChannels as $value => $label): ?>
|
||||||
|
<option value="<?= htmlspecialchars($value) ?>"><?= htmlspecialchars($label) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col field">
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<input type="checkbox" name="can_see_betas" value="1" style="width: auto; margin: 0;">
|
||||||
|
Accès aux versions BÊTA
|
||||||
|
</label>
|
||||||
|
<span class="muted" style="font-size: 11px;">Coche pour les testeurs internes / clients pilotes.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Notes internes (optionnel)</label>
|
<label>Notes internes (optionnel)</label>
|
||||||
<input type="text" name="notes" placeholder="Contact : jean@acme.com">
|
<input type="text" name="notes" placeholder="Contact : jean@acme.com">
|
||||||
@@ -143,6 +311,7 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<th>Émise</th>
|
<th>Émise</th>
|
||||||
<th>Expire</th>
|
<th>Expire</th>
|
||||||
<th>Machines</th>
|
<th>Machines</th>
|
||||||
|
<th>Channel / Bêta</th>
|
||||||
<th>Statut</th>
|
<th>Statut</th>
|
||||||
<th style="text-align:right">Actions</th>
|
<th style="text-align:right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -159,50 +328,393 @@ Layout::header('Licenses', 'licenses');
|
|||||||
<td>#<?= $l['id'] ?></td>
|
<td>#<?= $l['id'] ?></td>
|
||||||
<td>
|
<td>
|
||||||
<strong><?= htmlspecialchars($l['owner_name']) ?></strong>
|
<strong><?= htmlspecialchars($l['owner_name']) ?></strong>
|
||||||
|
<?php // Clé license affichée en monospace + bouton 1-clic
|
||||||
|
// pour copier dans le presse-papier. Permet à l'admin
|
||||||
|
// de la transmettre rapidement à un client sans passer
|
||||||
|
// par phpMyAdmin. Sensibilité OK : la page admin est
|
||||||
|
// déjà derrière auth + la clé seule ne suffit pas
|
||||||
|
// (validation serveur HMAC + machine_id requis). ?>
|
||||||
|
<div style="font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px;">
|
||||||
|
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 11px; color: #C5E1A5; word-break: break-all;"><?= htmlspecialchars($l['license_key']) ?></code>
|
||||||
|
<button type="button" class="copy-btn"
|
||||||
|
data-copy="<?= htmlspecialchars($l['license_key'], ENT_QUOTES) ?>"
|
||||||
|
title="Copier la clé license"
|
||||||
|
style="background: transparent; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; padding: 2px 8px; border-radius: 3px; font-size: 11px; line-height: 1;">📋</button>
|
||||||
|
</div>
|
||||||
<?php if (!empty($l['notes'])): ?>
|
<?php if (!empty($l['notes'])): ?>
|
||||||
<div class="muted" style="font-size: 11px;"><?= htmlspecialchars($l['notes']) ?></div>
|
<div class="muted" style="font-size: 11px; margin-top: 4px;"><?= htmlspecialchars($l['notes']) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
|
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
|
||||||
<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>
|
||||||
|
<!-- READ-ONLY : channel actuel + badge BÊTA si actif. Tous
|
||||||
|
les éditeurs sont passés dans le modal pour éviter
|
||||||
|
l'encombrement de la cellule. -->
|
||||||
|
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
|
||||||
|
title="Channel actuellement attribué">
|
||||||
|
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
|
||||||
|
</code>
|
||||||
|
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
|
||||||
|
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
|
||||||
|
<?php if ($hasLock): ?>
|
||||||
|
<span class="badge badge-secondary" style="margin-left: 4px;" title="Section Settings → Avancés verrouillée par mot de passe">🔒</span>
|
||||||
|
<?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;">
|
<?php $modalId = 'edit-license-' . (int)$l['id']; ?>
|
||||||
<summary class="btn btn-secondary">Prolonger</summary>
|
<div class="row-actions">
|
||||||
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
|
<button type="button" class="btn btn-primary"
|
||||||
|
onclick="document.getElementById('<?= $modalId ?>').showModal()">
|
||||||
|
✎ Modifier
|
||||||
|
</button>
|
||||||
|
<?php if (!$isRevoked): ?>
|
||||||
|
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="revoke">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<button class="btn btn-danger" type="submit">✕ Révoquer</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="post" style="display:inline">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="unrevoke">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<button class="btn btn-secondary" type="submit">↩ Réactiver</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php
|
||||||
|
// Modal d'édition (capturé en buffer, flush après </table>).
|
||||||
|
// Même pattern que versions.php : <dialog> dans <table> est
|
||||||
|
// foster-parented imprévisiblement → on émet hors de la table.
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<dialog id="<?= $modalId ?>" class="edit-modal">
|
||||||
|
<header>
|
||||||
|
<h3>License #<?= $l['id'] ?> — <?= htmlspecialchars($l['owner_name']) ?></h3>
|
||||||
|
<button type="button" class="close-x" aria-label="Fermer"
|
||||||
|
onclick="this.closest('dialog').close()">×</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="modal-tabs">
|
||||||
|
<button type="button" data-tab="prolong" class="active">Prolonger</button>
|
||||||
|
<button type="button" data-tab="slots">Slots</button>
|
||||||
|
<button type="button" data-tab="channel">Channel</button>
|
||||||
|
<button type="button" data-tab="beta">BÊTA</button>
|
||||||
|
<button type="button" data-tab="lock">Lock</button>
|
||||||
|
<button type="button" data-tab="contacts">Contacts</button>
|
||||||
|
<button type="button" data-tab="machines">Machines</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- TAB : Prolonger -->
|
||||||
|
<section data-tab="prolong">
|
||||||
|
<p class="section-intro">
|
||||||
|
Repousse la date d'expiration de l'entitlement.
|
||||||
|
Actuelle : <strong><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></strong>
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="extend">
|
<input type="hidden" name="action" value="extend">
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
<input type="date" name="new_until" required style="width: 150px;">
|
<div class="field">
|
||||||
<button class="btn btn-primary" type="submit">OK</button>
|
<label>Nouvelle date d'expiration</label>
|
||||||
|
<input type="date" name="new_until" required>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Prolonger</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Slots -->
|
||||||
|
<section data-tab="slots" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Nombre maximum de machines pouvant être activées simultanément.
|
||||||
|
Actuellement <strong><?= (int)$l['machines_count'] ?></strong>
|
||||||
|
active(s) sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
|
||||||
|
Pour réduire en-dessous du nombre actif, libère d'abord les slots
|
||||||
|
via l'onglet « Machines ».
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_max_machines">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Slots max</label>
|
||||||
|
<input type="number" name="max_machines" min="1" max="100"
|
||||||
|
value="<?= (int)$l['max_machines'] ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Slots</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Channel -->
|
||||||
|
<section data-tab="channel" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Quel manifest sert cette license ? « default » = manifest public.
|
||||||
|
Les channels privés (gérés sur la page <a href="channels.php">Channels</a>)
|
||||||
|
permettent de servir des versions ciblées (police, firefighter, …).
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_channel">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Channel</label>
|
||||||
|
<select name="channel">
|
||||||
|
<?php foreach ($availableChannels as $value => $label):
|
||||||
|
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
|
||||||
|
?>
|
||||||
|
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Channel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : BÊTA -->
|
||||||
|
<section data-tab="beta" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Si activé, le client voit en plus des versions stables toutes les
|
||||||
|
versions taggées <code>isBeta=true</code> dans le manifest. Sinon
|
||||||
|
elles lui sont invisibles (filtrées côté manifest signé).
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="toggle_betas">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="enabled" value="1"
|
||||||
|
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'checked' : '' ?>>
|
||||||
|
Voir les versions BÊTA
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Lock (settings password) -->
|
||||||
|
<section data-tab="lock" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Mot de passe qui verrouille la section Settings → Avancés du
|
||||||
|
launcher pour cette license. Vide = retirer le verrouillage.
|
||||||
|
Actuellement : <strong><?= $hasLock ? 'verrouillé 🔒' : 'pas verrouillé' ?></strong>
|
||||||
|
</p>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_settings_lock_password">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Mot de passe (≥4 caractères, ou vide pour retirer)</label>
|
||||||
|
<input type="password" name="settings_lock_password"
|
||||||
|
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe' ?>"
|
||||||
|
minlength="0" maxlength="100"
|
||||||
|
autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Lock</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Contacts (emails de notification release) -->
|
||||||
|
<section data-tab="contacts" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
Liste d'adresses email à notifier quand tu publies une nouvelle
|
||||||
|
version de PROSERVE accessible à cette license (matching channel +
|
||||||
|
entitlement). Format libre : une adresse par ligne, virgules, ou
|
||||||
|
point-virgules. Adresses invalides ignorées silencieusement.
|
||||||
|
La langue choisie ci-dessous est utilisée pour le contenu de l'email
|
||||||
|
(greeting + instructions) — les release notes elles-mêmes restent
|
||||||
|
toujours en anglais.
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
// Affichage en mode "1 par ligne" pour la lisibilité, peu importe
|
||||||
|
// comment c'est stocké en DB (CSV).
|
||||||
|
$currentEmails = Mailer::parseEmails($l['contact_emails'] ?? '');
|
||||||
|
$currentDisplay = implode("\n", $currentEmails);
|
||||||
|
$currentLang = trim((string)($l['language'] ?? ''));
|
||||||
|
$langOptions = [
|
||||||
|
'' => 'English (defaut)',
|
||||||
|
'fr' => 'Français',
|
||||||
|
'en' => 'English',
|
||||||
|
'es' => 'Español',
|
||||||
|
'de' => 'Deutsch',
|
||||||
|
'zh' => '中文 (Chinese)',
|
||||||
|
'th' => 'ภาษาไทย (Thai)',
|
||||||
|
'ar' => 'العربية (Arabic)',
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<form method="post">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="set_contact_emails">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<div class="field">
|
||||||
|
<label>Langue de l'email</label>
|
||||||
|
<select name="language">
|
||||||
|
<?php foreach ($langOptions as $code => $label):
|
||||||
|
$sel = ($currentLang === $code) ? 'selected' : '';
|
||||||
|
?>
|
||||||
|
<option value="<?= htmlspecialchars($code) ?>" <?= $sel ?>><?= htmlspecialchars($label) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<p class="hint" style="margin: 4px 0 0;">
|
||||||
|
Langue dans laquelle l'email de notification sera rédigé.
|
||||||
|
Le contenu des release notes reste toujours en anglais.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Adresses email <span class="muted" style="font-weight: normal;">(<?= count($currentEmails) ?> actuellement)</span></label>
|
||||||
|
<textarea name="contact_emails" rows="6"
|
||||||
|
placeholder="ops@asterionvr.com jerome@client.com technicien@partner.fr"
|
||||||
|
style="font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px;"><?= htmlspecialchars($currentDisplay) ?></textarea>
|
||||||
|
<p class="hint" style="margin: 4px 0 0;">
|
||||||
|
Une par ligne, ou séparées par <code>,</code> / <code>;</code>.
|
||||||
|
Vider le champ = retirer toutes les notifications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Enregistrer Contacts</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAB : Machines -->
|
||||||
|
<section data-tab="machines" hidden>
|
||||||
|
<p class="section-intro">
|
||||||
|
<strong><?= (int)$l['machines_count'] ?></strong> machine(s) active(s)
|
||||||
|
sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
|
||||||
|
La gestion fine machine par machine se fait via le tableau dépliable
|
||||||
|
ci-dessous (clic sur « X / Y » dans la colonne Machines de la row).
|
||||||
|
</p>
|
||||||
|
<?php if ($l['machines_count'] > 0): ?>
|
||||||
|
<div class="field">
|
||||||
|
<label>Action en bloc</label>
|
||||||
|
<form method="post" style="display:inline"
|
||||||
|
onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nLes slots redeviendront disponibles pour de nouvelles activations.')">
|
||||||
|
<?= Layout::csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="reset_machines">
|
||||||
|
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
||||||
|
<button class="btn btn-warning" type="submit">Libérer TOUTES les machines</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="muted" style="font-style: italic;">Aucune machine active sur cette license.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="modal-footer" style="margin: 20px -20px -20px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<?php $GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean(); ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// 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="8" 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;">
|
||||||
|
<?php // Note : pas de "Déplacer vers une autre license" ici —
|
||||||
|
// ce serait une feature DB-only fragile (le launcher
|
||||||
|
// ré-activerait automatiquement la machine sur sa
|
||||||
|
// license d'origine au prochain validate puisque sa
|
||||||
|
// clé license stockée n'a pas changé). La migration
|
||||||
|
// d'une machine vers une autre license se fait côté
|
||||||
|
// launcher : Settings → License → entrer la nouvelle
|
||||||
|
// clé. ?>
|
||||||
|
<form method="post" style="display:inline"
|
||||||
|
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>
|
</details>
|
||||||
<?php if ($l['machines_count'] > 0): ?>
|
|
||||||
<form method="post" style="display:inline" onsubmit="return confirm('Libérer toutes les machines de cette license ?')">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="reset_machines">
|
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
|
||||||
<button class="btn btn-secondary" type="submit" title="Libère les slots machines occupés">Libérer machines</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if (!$isRevoked): ?>
|
|
||||||
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="revoke">
|
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
|
||||||
<button class="btn btn-danger" type="submit">Révoquer</button>
|
|
||||||
</form>
|
|
||||||
<?php else: ?>
|
|
||||||
<form method="post" style="display:inline">
|
|
||||||
<?= Layout::csrfField() ?>
|
|
||||||
<input type="hidden" name="action" value="unrevoke">
|
|
||||||
<input type="hidden" name="id" value="<?= $l['id'] ?>">
|
|
||||||
<button class="btn btn-secondary" type="submit">Réactiver</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
||||||
@@ -210,4 +722,70 @@ Layout::header('Licenses', 'licenses');
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php // Dialogs émis hors de la <table> pour que showModal() fonctionne. ?>
|
||||||
|
<?= $GLOBALS['__editDialogs'] ?? '' ?>
|
||||||
|
|
||||||
|
<!-- Handler global pour les onglets des modals (délégation sur document). -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
// 1. Tabs des modals d'édition license
|
||||||
|
var btn = e.target.closest('.modal-tabs button[data-tab]');
|
||||||
|
if (btn) {
|
||||||
|
var tabName = btn.dataset.tab;
|
||||||
|
var modal = btn.closest('dialog');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
|
||||||
|
b.classList.toggle('active', b === btn);
|
||||||
|
});
|
||||||
|
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
|
||||||
|
s.hidden = s.dataset.tab !== tabName;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Boutons "📋 Copier" (clé license) — copie data-copy via Clipboard API
|
||||||
|
// + feedback visuel temporaire (1.5 s) sur le bouton lui-même.
|
||||||
|
var copyBtn = e.target.closest('[data-copy]');
|
||||||
|
if (copyBtn) {
|
||||||
|
var text = copyBtn.dataset.copy || '';
|
||||||
|
var original = copyBtn.textContent;
|
||||||
|
// Clipboard API moderne (Chrome 66+, Firefox 63+, Edge 79+, Safari 13.1+).
|
||||||
|
// Fallback execCommand pour les browsers très anciens.
|
||||||
|
var copyOk = function () {
|
||||||
|
copyBtn.textContent = '✓';
|
||||||
|
copyBtn.style.color = '#16A34A';
|
||||||
|
setTimeout(function () {
|
||||||
|
copyBtn.textContent = original;
|
||||||
|
copyBtn.style.color = '';
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(copyOk, function () {
|
||||||
|
// Échec d'autorisation (rare en HTTPS sur même origine) — fallback
|
||||||
|
fallbackCopy(text, copyOk);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text, copyOk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback copie via textarea hidden + execCommand. Marche dans tous les
|
||||||
|
// browsers sans avoir besoin de la permission Clipboard API.
|
||||||
|
function fallbackCopy(text, onSuccess) {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try { document.execCommand('copy'); onSuccess(); }
|
||||||
|
catch (e) { /* silent */ }
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<?php Layout::footer();
|
<?php Layout::footer();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,4 +40,32 @@ return [
|
|||||||
// php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);"
|
// php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);"
|
||||||
// puis colle le résultat ci-dessous.
|
// puis colle le résultat ci-dessous.
|
||||||
'admin_password_hash' => '',
|
'admin_password_hash' => '',
|
||||||
|
|
||||||
|
// === NOTIFICATIONS EMAIL (release announce) ===
|
||||||
|
// Utilisé par versions.php → action « ✉ Notifier » pour prévenir les
|
||||||
|
// contacts d'une license de la sortie d'une nouvelle version.
|
||||||
|
// Si SMTP non configuré, on retombe sur mail() natif PHP (qui marche sur
|
||||||
|
// OVH mutualisé via le relais SMTP local sans config additionnelle).
|
||||||
|
'notifications' => [
|
||||||
|
// Adresse "From" de l'email (doit être autorisée par OVH — typiquement
|
||||||
|
// une adresse hébergée sur ton domaine principal).
|
||||||
|
'from_address' => 'no-reply@asterionvr.com',
|
||||||
|
'from_name' => 'ASTERION VR — PROSERVE Launcher',
|
||||||
|
// Optionnel : Reply-To si tu veux que les réponses arrivent ailleurs
|
||||||
|
// (ex. boîte support partagée). Vide = pas de header Reply-To.
|
||||||
|
'reply_to' => '',
|
||||||
|
// Logo affiché en haut de l'email. Par défaut, l'image est EMBED INLINE
|
||||||
|
// via CID (Content-ID, pièce jointe MIME multipart/related). Avantage
|
||||||
|
// vs hotlink HTTP : Outlook (et la plupart des MUA) affiche l'image
|
||||||
|
// SANS demander la permission « Télécharger les images » au destinataire.
|
||||||
|
// Le path par défaut pointe vers admin/assets/asterion-logo.png — copié
|
||||||
|
// depuis le repo, tu peux remplacer le fichier librement (même URL servie).
|
||||||
|
'logo_path' => null, // null = auto-détect admin/assets/asterion-logo.png
|
||||||
|
// Fallback URL hotlink si tu préfères servir le logo depuis un CDN
|
||||||
|
// public au lieu du CID embed. UTILISÉ UNIQUEMENT si logo_path est null
|
||||||
|
// ou que le fichier est introuvable. Note : les MUA bloquent souvent
|
||||||
|
// les hotlinks par défaut → le destinataire devra cliquer « afficher
|
||||||
|
// les images » pour voir le logo. Préfère le CID embed (laisse logo_path).
|
||||||
|
'logo_url' => '',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -41,12 +41,17 @@ $route = trim((string)($_GET['route'] ?? ''), '/');
|
|||||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
|
||||||
if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
|
if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
|
||||||
|
// Crypto requis : depuis v0.27.0 le manifest est re-signé à la volée
|
||||||
|
// selon le channel demandé (filtrage server-side).
|
||||||
|
require __DIR__ . '/lib/Crypto.php';
|
||||||
require __DIR__ . '/routes/Manifest.php';
|
require __DIR__ . '/routes/Manifest.php';
|
||||||
\PSLauncher\Routes\Manifest::handle($config);
|
\PSLauncher\Routes\Manifest::handle($config);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'GET' && preg_match('#^releasenotes/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
|
// Release notes : accepte 'X.Y.Z' ou 'X.Y.Z.B' (4ᵉ digit = itération dev/test
|
||||||
|
// optionnelle) OU id stable 'v' + 8 hex (v0.27.1+).
|
||||||
|
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
|
||||||
require __DIR__ . '/routes/Releasenotes.php';
|
require __DIR__ . '/routes/Releasenotes.php';
|
||||||
\PSLauncher\Routes\Releasenotes::handle($m[1]);
|
\PSLauncher\Routes\Releasenotes::handle($m[1]);
|
||||||
return;
|
return;
|
||||||
@@ -60,7 +65,8 @@ if ($method === 'POST' && $route === 'license/validate') {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
|
// Download URL : accepte X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
|
||||||
|
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
|
||||||
require __DIR__ . '/lib/Db.php';
|
require __DIR__ . '/lib/Db.php';
|
||||||
require __DIR__ . '/lib/Crypto.php';
|
require __DIR__ . '/lib/Crypto.php';
|
||||||
require __DIR__ . '/routes/DownloadUrl.php';
|
require __DIR__ . '/routes/DownloadUrl.php';
|
||||||
|
|||||||
@@ -19,20 +19,40 @@ final class DownloadUrl
|
|||||||
{
|
{
|
||||||
public static function handle(array $config, string $version): void
|
public static function handle(array $config, string $version): void
|
||||||
{
|
{
|
||||||
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
|
// Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
|
||||||
|
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $version)) {
|
||||||
Response::error('invalid_version', 'Version invalide', 400);
|
Response::error('invalid_version', 'Version invalide', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth via Authorization: Bearer <licenseKey>
|
// Auth via Authorization: Bearer <licenseKey>
|
||||||
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
// Apache OVH (et autres hébergements FastCGI) strippe parfois le header. On
|
||||||
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) {
|
// tente plusieurs sources :
|
||||||
// Fallback : certains hébergements masquent Authorization. On accepte aussi ?key=
|
// - $_SERVER['HTTP_AUTHORIZATION'] (cas standard)
|
||||||
$licenseKey = trim((string)($_GET['key'] ?? ''));
|
// - $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] (mod_rewrite passthrough)
|
||||||
if ($licenseKey === '') {
|
// - apache_request_headers() (présent quand mod_php)
|
||||||
Response::error('unauthorized', 'License key requise (Authorization: Bearer ... ou ?key=...)', 401);
|
// - getallheaders() (idem)
|
||||||
}
|
// - ?key=... (fallback explicite)
|
||||||
} else {
|
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
|
||||||
|
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||||
|
?? '';
|
||||||
|
if ($authHeader === '' && function_exists('apache_request_headers')) {
|
||||||
|
$h = apache_request_headers();
|
||||||
|
$authHeader = $h['Authorization'] ?? $h['authorization'] ?? '';
|
||||||
|
}
|
||||||
|
if ($authHeader === '' && function_exists('getallheaders')) {
|
||||||
|
$h = getallheaders();
|
||||||
|
$authHeader = $h['Authorization'] ?? $h['authorization'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$licenseKey = '';
|
||||||
|
if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) {
|
||||||
$licenseKey = trim($m[1]);
|
$licenseKey = trim($m[1]);
|
||||||
|
} else {
|
||||||
|
$licenseKey = trim((string)($_GET['key'] ?? ''));
|
||||||
|
}
|
||||||
|
if ($licenseKey === '') {
|
||||||
|
Response::error('unauthorized',
|
||||||
|
'License key requise (Authorization: Bearer ... ou ?key=...)', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = Db::get($config);
|
$db = Db::get($config);
|
||||||
@@ -55,12 +75,36 @@ final class DownloadUrl
|
|||||||
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
|
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
|
||||||
}
|
}
|
||||||
$manifest = json_decode(file_get_contents($manifestPath), true);
|
$manifest = json_decode(file_get_contents($manifestPath), true);
|
||||||
|
|
||||||
|
// Filename attendu, envoyé par le client (extrait de son propre manifest signé).
|
||||||
|
// Sert à disambiguer les manifestes multi-channels où plusieurs entrées
|
||||||
|
// partagent le même numéro de version (ex : proserve-firefighter-1.5.4.32 et
|
||||||
|
// proserve-full-1.5.4.32 sur v1.5.4.32). Sans ce filtre, le foreach ci-dessous
|
||||||
|
// retournait la 1re entrée matchant le numéro → l'URL signée pointait vers le
|
||||||
|
// MAUVAIS ZIP, et le client détectait le mismatch filename manifest vs signé
|
||||||
|
// et abortait (garde-fou côté MainViewModel.InstallVersionAsync). Optionnel
|
||||||
|
// pour rétro-compat avec les vieux clients (v1.0.4-) qui ne l'envoient pas ;
|
||||||
|
// dans ce cas la 1re entrée gagne, comme avant.
|
||||||
|
$expectedFilename = trim((string)($_GET['filename'] ?? ''));
|
||||||
|
// Whitelist défensive — même règle que la validation du filename lu du manifest
|
||||||
|
// plus bas dans cette route. Bloque path traversal via query param.
|
||||||
|
if ($expectedFilename !== '' && !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $expectedFilename)) {
|
||||||
|
Response::error('invalid_filename', "Query filename invalide : '{$expectedFilename}'", 400);
|
||||||
|
}
|
||||||
|
|
||||||
$entry = null;
|
$entry = null;
|
||||||
foreach ($manifest['versions'] ?? [] as $v) {
|
foreach ($manifest['versions'] ?? [] as $v) {
|
||||||
if (($v['version'] ?? '') === $version) { $entry = $v; break; }
|
if (($v['version'] ?? '') !== $version) continue;
|
||||||
|
if ($expectedFilename !== '') {
|
||||||
|
$entryFilename = basename(parse_url((string)($v['download']['url'] ?? ''), PHP_URL_PATH) ?: '');
|
||||||
|
if ($entryFilename !== $expectedFilename) continue;
|
||||||
|
}
|
||||||
|
$entry = $v;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (!$entry) {
|
if (!$entry) {
|
||||||
Response::error('version_not_found', "Version {$version} absente du manifest", 404);
|
$suffix = $expectedFilename !== '' ? " (filename attendu : {$expectedFilename})" : '';
|
||||||
|
Response::error('version_not_found', "Version {$version} absente du manifest{$suffix}", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérif droits téléchargement
|
// Vérif droits téléchargement
|
||||||
@@ -76,10 +120,55 @@ final class DownloadUrl
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extrait le NOM DE FICHIER depuis l'URL du manifest (= source de vérité).
|
||||||
|
// Avant : on utilisait un template hardcodé `proserve-{version}.zip`,
|
||||||
|
// ce qui ignorait toute personnalisation du nom (ex. opérateur qui rename
|
||||||
|
// en `proserve-full-1.5.4.zip` pour buster un cache CDN). Conséquence :
|
||||||
|
// l'endpoint retournait une URL signée pointant vers un fichier inexistant
|
||||||
|
// → 404 systématique côté client sans aucune indication serveur.
|
||||||
|
// Maintenant : on lit `download.url` du manifest, on extrait le filename
|
||||||
|
// via parse_url + basename, on vérifie qu'il existe physiquement dans
|
||||||
|
// /builds/, et seulement après on signe.
|
||||||
|
$manifestUrl = $entry['download']['url'] ?? '';
|
||||||
|
if ($manifestUrl === '') {
|
||||||
|
Response::error('manifest_incomplete',
|
||||||
|
"L'entrée manifest pour v{$version} est sans download.url", 500);
|
||||||
|
}
|
||||||
|
$parsedUrl = parse_url($manifestUrl);
|
||||||
|
$urlPath = $parsedUrl['path'] ?? '';
|
||||||
|
$filename = basename($urlPath);
|
||||||
|
// Whitelist défensive sur le filename : caractères safe + suffixe .zip.
|
||||||
|
// Évite path traversal et autres injections via un manifest corrompu.
|
||||||
|
if ($filename === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $filename)) {
|
||||||
|
Response::error('manifest_invalid_filename',
|
||||||
|
"Nom de fichier invalide extrait du manifest pour v{$version} : '{$filename}'", 500);
|
||||||
|
}
|
||||||
|
// Vérifie que le fichier existe physiquement avant de signer une URL morte.
|
||||||
|
// Cas concret de bug remonté côté client : l'opérateur rename le ZIP dans
|
||||||
|
// /builds/ mais oublie de mettre à jour versions.json (ou inverse). Au
|
||||||
|
// lieu d'envoyer le client en 404 silencieux, on retourne une erreur
|
||||||
|
// serveur claire qui apparaît dans les logs PHP + client.
|
||||||
|
$buildsDir = dirname(__DIR__, 2) . '/builds';
|
||||||
|
$physicalPath = $buildsDir . '/' . $filename;
|
||||||
|
if (!is_file($physicalPath)) {
|
||||||
|
Response::error('file_missing',
|
||||||
|
"Le fichier ZIP « {$filename} » référencé par le manifest pour v{$version} est absent du dossier /builds/. Vérifie que le manifest et le filesystem sont synchros.",
|
||||||
|
500,
|
||||||
|
[
|
||||||
|
'manifestUrl' => $manifestUrl,
|
||||||
|
'expectedPath' => $physicalPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Génère l'URL HMAC-signée
|
// Génère l'URL HMAC-signée
|
||||||
|
// TTL : 6 h. Compromis entre :
|
||||||
|
// - sécurité (limite la fenêtre de replay si une URL fuit)
|
||||||
|
// - utilisabilité (un user en ADSL 8 Mbps mettra ~4 h pour DL 14 Go)
|
||||||
|
// Pour une connexion plus lente, le client sait auto-refresher l'URL
|
||||||
|
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
|
||||||
$baseUrl = rtrim($config['base_url'], '/');
|
$baseUrl = rtrim($config['base_url'], '/');
|
||||||
$relPath = '/builds/proserve-' . $version . '.zip';
|
$relPath = '/builds/' . $filename;
|
||||||
$exp = time() + 3600; // 1 h
|
$exp = time() + 21600; // 6 h
|
||||||
$secret = $config['hmac_secret'] ?? '';
|
$secret = $config['hmac_secret'] ?? '';
|
||||||
if ($secret === '') {
|
if ($secret === '') {
|
||||||
Response::error('config_error', 'hmac_secret non configuré', 500);
|
Response::error('config_error', 'hmac_secret non configuré', 500);
|
||||||
|
|||||||
@@ -3,21 +3,156 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace PSLauncher\Routes;
|
namespace PSLauncher\Routes;
|
||||||
|
|
||||||
|
use PSLauncher\Crypto;
|
||||||
use PSLauncher\Response;
|
use PSLauncher\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint manifest public. Reçoit ?channel=X depuis le launcher (channel
|
||||||
|
* provenant de la license signée Ed25519). On filtre les versions pour ne
|
||||||
|
* renvoyer que celles taggées sur ce channel ou sur "default", et on re-signe
|
||||||
|
* le manifest filtré à la volée avec la clé privée Ed25519.
|
||||||
|
*
|
||||||
|
* Sémantique additive : un client sur channel "police" voit "default" + "police".
|
||||||
|
* Pour rendre une version invisible au public, ne PAS la tagger "default".
|
||||||
|
*/
|
||||||
final class Manifest
|
final class Manifest
|
||||||
{
|
{
|
||||||
|
private const CHANNEL_REGEX = '/^[a-z0-9_-]{1,64}$/';
|
||||||
|
|
||||||
public static function handle(array $config): void
|
public static function handle(array $config): void
|
||||||
{
|
{
|
||||||
$file = dirname(__DIR__, 2) . '/manifest/versions.json';
|
$manifestPath = dirname(__DIR__, 2) . '/manifest/versions.json';
|
||||||
if (!is_file($file)) {
|
if (!is_file($manifestPath)) {
|
||||||
Response::error('manifest_missing', 'versions.json not found on server', 404);
|
Response::error('manifest_missing', 'versions.json not found on server', 404);
|
||||||
}
|
}
|
||||||
// Cache court pour absorber les pics, doit être revalidé pour ne pas masquer une nouvelle version
|
|
||||||
|
$raw = file_get_contents($manifestPath);
|
||||||
|
$manifest = json_decode((string)$raw, true);
|
||||||
|
if (!is_array($manifest)) {
|
||||||
|
Response::error('manifest_invalid', 'versions.json is not valid JSON', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel demandé par le client. Whitelist regex anti-injection. Un
|
||||||
|
// channel inconnu / mal formé est traité comme NULL → le client voit
|
||||||
|
// uniquement les versions "default".
|
||||||
|
$clientChannel = $_GET['channel'] ?? null;
|
||||||
|
if (!is_string($clientChannel) || $clientChannel === '' || !preg_match(self::CHANNEL_REGEX, $clientChannel)) {
|
||||||
|
$clientChannel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-channel opt-in : les clients v1.0.10+ savent afficher plusieurs
|
||||||
|
// entries au même numéro de version (channels distincts). Ils passent
|
||||||
|
// ?multiChannel=1 pour signaler la capabilité. Les clients v1.0.9- (et
|
||||||
|
// avant refactor row-by-Id) crasheraient sur ToDictionary(v.Version) →
|
||||||
|
// le serveur reste en mode dédup pour eux (comportement historique).
|
||||||
|
$multiChannel = !empty($_GET['multiChannel']);
|
||||||
|
|
||||||
|
$manifest['versions'] = self::filterVersions(
|
||||||
|
$manifest['versions'] ?? [],
|
||||||
|
$clientChannel,
|
||||||
|
$multiChannel
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-signature à la volée. La clé privée Ed25519 est dans config.php
|
||||||
|
// (sert déjà aux licenses), accessible uniquement depuis le serveur.
|
||||||
|
// Coût : ~1ms d'Ed25519 par requête, négligeable vs le réseau.
|
||||||
|
$sk = $config['ed25519']['private_key_hex'] ?? '';
|
||||||
|
if ($sk !== '') {
|
||||||
|
unset($manifest['signature']); // important : canonical sans la signature
|
||||||
|
$manifest['signature'] = Crypto::signEd25519(Crypto::canonicalJson($manifest), $sk);
|
||||||
|
} else {
|
||||||
|
unset($manifest['signature']); // pas de clé = pas de signature, le client échouera la vérif
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
// Cache court pour absorber les pics. ETag inclut le channel pour que
|
||||||
|
// les caches HTTP intermédiaires séparent bien les vues par client.
|
||||||
header('Cache-Control: public, max-age=300, must-revalidate');
|
header('Cache-Control: public, max-age=300, must-revalidate');
|
||||||
header('ETag: "' . md5_file($file) . '"');
|
header('ETag: "' . md5(($clientChannel ?? '') . '|' . md5_file($manifestPath)) . '"');
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
header('Content-Length: ' . filesize($file));
|
header('Content-Length: ' . strlen((string)$body));
|
||||||
readfile($file);
|
echo $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtre + dédupe par numéro de version.
|
||||||
|
*
|
||||||
|
* Étape 1 — filter : on garde les versions visibles par ce client. Un
|
||||||
|
* client sur channel X voit les entries taggées 'default' OU 'X'.
|
||||||
|
*
|
||||||
|
* Étape 2 — dedupe : pour chaque numéro de version qui apparaît plusieurs
|
||||||
|
* fois (cas v1.5.3 default + v1.5.3 firefighter), on ne renvoie qu'UNE
|
||||||
|
* seule entry — la plus spécifique au client. Priorité : l'entry taggée
|
||||||
|
* avec le channel propre du client gagne ; à défaut, l'entry default
|
||||||
|
* sert de fallback. Sans cette dédup côté serveur, le launcher recevait
|
||||||
|
* deux entries v1.5.3 et son `ToDictionary(v => v.Version)` collisionnait
|
||||||
|
* silencieusement, ce qui faisait apparaître dans l'UI le mauvais ZIP
|
||||||
|
* pour un user firefighter.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string,mixed>> $versions
|
||||||
|
* @return list<array<string,mixed>>
|
||||||
|
*/
|
||||||
|
private static function filterVersions(array $versions, ?string $clientChannel, bool $multiChannel = false): array
|
||||||
|
{
|
||||||
|
// Étape 1 : filter visible par ce client
|
||||||
|
$visible = [];
|
||||||
|
foreach ($versions as $v) {
|
||||||
|
$channels = (isset($v['channels']) && is_array($v['channels']) && !empty($v['channels']))
|
||||||
|
? $v['channels']
|
||||||
|
: ['default'];
|
||||||
|
|
||||||
|
$isPublic = in_array('default', $channels, true);
|
||||||
|
$matchesUser = $clientChannel !== null && in_array($clientChannel, $channels, true);
|
||||||
|
|
||||||
|
if ($isPublic || $matchesUser) {
|
||||||
|
$visible[] = ['entry' => $v, 'channels' => $channels];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client v1.0.10+ : bypass la dédup, retourne toutes les entries visibles.
|
||||||
|
// Le client sait maintenant afficher plusieurs rows au même numéro (keyage
|
||||||
|
// par entryId côté RebuildList) et bloque les collisions d'install côté
|
||||||
|
// guard. Le badge channel n'apparaît que si plusieurs entries partagent
|
||||||
|
// le numéro (v1.0.9+) → UX propre.
|
||||||
|
if ($multiChannel) {
|
||||||
|
return array_map(fn($item) => $item['entry'], $visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Étape 2 (clients v1.0.9-) : group by version, pick most specific per
|
||||||
|
// group. Comportement historique pour ne pas crasher les vieux clients
|
||||||
|
// qui font ToDictionary(v.Version) et exploseraient sur des duplicates.
|
||||||
|
$byVersion = [];
|
||||||
|
foreach ($visible as $item) {
|
||||||
|
$key = (string)($item['entry']['version'] ?? '?');
|
||||||
|
$byVersion[$key][] = $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($byVersion as $items) {
|
||||||
|
if (count($items) === 1) {
|
||||||
|
$result[] = $items[0]['entry'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Plusieurs entries pour ce numéro : on cherche d'abord une qui
|
||||||
|
// matche le channel spécifique du client (firefighter, police…),
|
||||||
|
// sinon on retombe sur l'entry default.
|
||||||
|
$specific = null;
|
||||||
|
$defaultEntry = null;
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if ($clientChannel !== null
|
||||||
|
&& $specific === null
|
||||||
|
&& in_array($clientChannel, $item['channels'], true)) {
|
||||||
|
$specific = $item;
|
||||||
|
}
|
||||||
|
if ($defaultEntry === null && in_array('default', $item['channels'], true)) {
|
||||||
|
$defaultEntry = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$picked = $specific ?? $defaultEntry ?? $items[0];
|
||||||
|
$result[] = $picked['entry'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,28 @@ namespace PSLauncher\Routes;
|
|||||||
|
|
||||||
use PSLauncher\Response;
|
use PSLauncher\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release notes endpoint. Accepte deux formats d'identifiant :
|
||||||
|
* - id stable d'entrée : 'v' + 8 hex chars (format depuis v0.27.1, supporte
|
||||||
|
* plusieurs entries de même version)
|
||||||
|
* - numéro de version legacy : X.Y.Z (compat ascendante avec les notes
|
||||||
|
* créées avant v0.27.1)
|
||||||
|
*/
|
||||||
final class Releasenotes
|
final class Releasenotes
|
||||||
{
|
{
|
||||||
public static function handle(string $version): void
|
public static function handle(string $key): void
|
||||||
{
|
{
|
||||||
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
|
$isEntryId = (bool)preg_match('/^v[0-9a-f]{8}$/', $key);
|
||||||
Response::error('invalid_version', 'Bad version format', 400);
|
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||||
|
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $key);
|
||||||
|
|
||||||
|
if (!$isEntryId && !$isVersion) {
|
||||||
|
Response::error('invalid_id', 'Bad release-notes identifier', 400);
|
||||||
}
|
}
|
||||||
$file = dirname(__DIR__, 2) . "/releasenotes/{$version}.md";
|
|
||||||
|
$file = dirname(__DIR__, 2) . "/releasenotes/{$key}.md";
|
||||||
if (!is_file($file)) {
|
if (!is_file($file)) {
|
||||||
Response::error('not_found', "Release notes for {$version} not found", 404);
|
Response::error('not_found', "Release notes for {$key} not found", 404);
|
||||||
}
|
}
|
||||||
header('Cache-Control: public, max-age=3600, must-revalidate');
|
header('Cache-Control: public, max-age=3600, must-revalidate');
|
||||||
header('Content-Type: text/markdown; charset=utf-8');
|
header('Content-Type: text/markdown; charset=utf-8');
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final class ValidateLicense
|
|||||||
|
|
||||||
$stmt = $db->prepare(
|
$stmt = $db->prepare(
|
||||||
'SELECT id, license_key, owner_name, issued_at, download_entitlement_until,
|
'SELECT id, license_key, owner_name, issued_at, download_entitlement_until,
|
||||||
max_machines, revoked_at
|
max_machines, channel, can_see_betas, settings_lock_password_hash, revoked_at
|
||||||
FROM licenses WHERE license_key = ? LIMIT 1'
|
FROM licenses WHERE license_key = ? LIMIT 1'
|
||||||
);
|
);
|
||||||
$stmt->execute([$licenseKey]);
|
$stmt->execute([$licenseKey]);
|
||||||
@@ -82,6 +82,23 @@ final class ValidateLicense
|
|||||||
$serverTime = (new \DateTimeImmutable('now', $utc))->format($fmt);
|
$serverTime = (new \DateTimeImmutable('now', $utc))->format($fmt);
|
||||||
$expired = strtotime($lic['download_entitlement_until']) < time();
|
$expired = strtotime($lic['download_entitlement_until']) < time();
|
||||||
|
|
||||||
|
// ATTENTION : ordre des clés CRITIQUE pour la signature Ed25519.
|
||||||
|
// Crypto::canonicalJson NE TRIE PAS — il prend l'ordre tel quel. Le client
|
||||||
|
// C# (LicenseService.CanonicalBytesFor) reconstruit le même dictionnaire
|
||||||
|
// dans le même ordre. Toute modif ici doit être miroir côté client.
|
||||||
|
// channel : NULL en DB → null JSON (visible dans le canonical comme "channel":null).
|
||||||
|
// can_see_betas : 0/1 en DB → bool JSON.
|
||||||
|
$channel = isset($lic['channel']) && $lic['channel'] !== null && $lic['channel'] !== ''
|
||||||
|
? (string)$lic['channel']
|
||||||
|
: null;
|
||||||
|
$canSeeBetas = (bool)($lic['can_see_betas'] ?? 0);
|
||||||
|
// SettingsLockPasswordHash : présent depuis migration 003, NULL = pas de
|
||||||
|
// verrouillage. On renvoie tel quel (null ou hex 64). Le launcher applique
|
||||||
|
// côté ISettingsLockService.SetPasswordHash après validation signée.
|
||||||
|
$settingsLockHash = isset($lic['settings_lock_password_hash']) && $lic['settings_lock_password_hash'] !== ''
|
||||||
|
? (string)$lic['settings_lock_password_hash']
|
||||||
|
: null;
|
||||||
|
|
||||||
$payload = [
|
$payload = [
|
||||||
'status' => $expired ? 'expired' : 'valid',
|
'status' => $expired ? 'expired' : 'valid',
|
||||||
'licenseId' => 'lic_' . $lic['id'],
|
'licenseId' => 'lic_' . $lic['id'],
|
||||||
@@ -89,6 +106,9 @@ final class ValidateLicense
|
|||||||
'issuedAt' => $issuedAt,
|
'issuedAt' => $issuedAt,
|
||||||
'downloadEntitlementUntil' => $entUntil,
|
'downloadEntitlementUntil' => $entUntil,
|
||||||
'maxMachines' => (int)$lic['max_machines'],
|
'maxMachines' => (int)$lic['max_machines'],
|
||||||
|
'channel' => $channel,
|
||||||
|
'canSeeBetas' => $canSeeBetas,
|
||||||
|
'settingsLockPasswordHash' => $settingsLockHash,
|
||||||
'serverTime' => $serverTime,
|
'serverTime' => $serverTime,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
9
server/builds/.htaccess
Normal file
9
server/builds/.htaccess
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Tout accès direct à un .zip passe d'abord par le gardien gate.php qui vérifie
|
||||||
|
# la signature HMAC + l'expiration. Si OK, le gardien sert le fichier (avec
|
||||||
|
# support Range pour la reprise). Sinon, 403.
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /PS_Launcher/builds/
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f
|
||||||
|
RewriteCond %{REQUEST_URI} \.zip$
|
||||||
|
RewriteRule ^(.*)$ gate.php?file=$1 [QSA,L]
|
||||||
93
server/builds/gate.php
Normal file
93
server/builds/gate.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Gardien des téléchargements de builds. Sert un .zip seulement si l'URL est
|
||||||
|
* accompagnée d'une signature HMAC valide non expirée, signée par la même
|
||||||
|
* clé que /api/download-url/{version}. Supporte les Range: requests pour la
|
||||||
|
* reprise (dégradé : on utilise readfile + fseek manuellement).
|
||||||
|
*
|
||||||
|
* Pour les hébergements où on veut désactiver la protection (tests internes), il
|
||||||
|
* suffit de retirer le RewriteRule du .htaccess voisin — Apache servira alors
|
||||||
|
* directement le fichier statique avec son support natif des Range.
|
||||||
|
*/
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
set_time_limit(0);
|
||||||
|
ignore_user_abort(true);
|
||||||
|
|
||||||
|
require __DIR__ . '/../api/lib/Crypto.php';
|
||||||
|
$config = require __DIR__ . '/../api/config.php';
|
||||||
|
$secret = $config['hmac_secret'] ?? '';
|
||||||
|
|
||||||
|
function deny(string $reason, int $code = 403): never
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Forbidden: $reason\n";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = trim((string)($_GET['file'] ?? ''), '/');
|
||||||
|
if ($file === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $file)) deny('invalid file');
|
||||||
|
|
||||||
|
$path = __DIR__ . '/' . $file;
|
||||||
|
if (!is_file($path)) deny('not found', 404);
|
||||||
|
|
||||||
|
$exp = (int)($_GET['exp'] ?? 0);
|
||||||
|
$lic = (string)($_GET['lic'] ?? '');
|
||||||
|
$sig = (string)($_GET['sig'] ?? '');
|
||||||
|
|
||||||
|
if ($exp <= 0 || $lic === '' || $sig === '') deny('missing params');
|
||||||
|
if ($exp < time()) deny('expired');
|
||||||
|
if ($secret === '') deny('server config error', 500);
|
||||||
|
|
||||||
|
$relPath = '/builds/' . $file;
|
||||||
|
$expected = \PSLauncher\Crypto::hmacHex($relPath . '|' . $exp . '|' . $lic, $secret);
|
||||||
|
if (!hash_equals($expected, $sig)) deny('bad signature');
|
||||||
|
|
||||||
|
// Sert le fichier avec support Range
|
||||||
|
$size = filesize($path);
|
||||||
|
$mtime = filemtime($path) ?: 0;
|
||||||
|
$start = 0;
|
||||||
|
$end = $size - 1;
|
||||||
|
|
||||||
|
header('Content-Type: application/zip');
|
||||||
|
header('Accept-Ranges: bytes');
|
||||||
|
// ⚠️ NE PAS utiliser md5_file($path) ici : sur un fichier 14 Go, ça relit
|
||||||
|
// l'intégralité du ZIP à chaque requête (~30 s-5 min selon disque). Avec
|
||||||
|
// 8 segments parallèles + HEAD probe, ça multipliait par 9 le temps avant
|
||||||
|
// le premier byte. On utilise un ETag dérivé size+mtime, équivalent à ce
|
||||||
|
// qu'Apache fait nativement pour les fichiers statiques. Change quand on
|
||||||
|
// upload un nouveau ZIP, donc cache invalidation correcte.
|
||||||
|
header('ETag: "' . dechex($size) . '-' . dechex($mtime) . '"');
|
||||||
|
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
|
||||||
|
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
|
||||||
|
$start = (int)$m[1];
|
||||||
|
if ($m[2] !== '') $end = min((int)$m[2], $size - 1);
|
||||||
|
if ($start > $end || $start >= $size) {
|
||||||
|
http_response_code(416);
|
||||||
|
header("Content-Range: bytes */$size");
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
http_response_code(206);
|
||||||
|
header("Content-Range: bytes $start-$end/$size");
|
||||||
|
}
|
||||||
|
|
||||||
|
$length = $end - $start + 1;
|
||||||
|
header('Content-Length: ' . $length);
|
||||||
|
|
||||||
|
$fp = fopen($path, 'rb');
|
||||||
|
fseek($fp, $start);
|
||||||
|
$bufSize = 1 << 20; // 1 MiB
|
||||||
|
$remaining = $length;
|
||||||
|
while ($remaining > 0 && !feof($fp) && !connection_aborted()) {
|
||||||
|
$chunk = fread($fp, (int)min($bufSize, $remaining));
|
||||||
|
if ($chunk === false) break;
|
||||||
|
echo $chunk;
|
||||||
|
@ob_flush();
|
||||||
|
@flush();
|
||||||
|
$remaining -= strlen($chunk);
|
||||||
|
}
|
||||||
|
fclose($fp);
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"version": "1.4.6",
|
"version": "1.4.6",
|
||||||
"releasedAt": "2026-04-29T10:00:00Z",
|
"releasedAt": "2026-04-29T10:00:00Z",
|
||||||
"executable": "PROSERVE_UE_5_5.exe",
|
"executable": "PROSERVE_UE_5_5.exe",
|
||||||
"installFolderTemplate": "Proserve v{version}",
|
"installFolderTemplate": "PROSERVE v{version}",
|
||||||
"download": {
|
"download": {
|
||||||
"url": "https://asterionvr.com/PS_Launcher/builds/proserve-1.4.6.zip",
|
"url": "https://asterionvr.com/PS_Launcher/builds/proserve-1.4.6.zip",
|
||||||
"sizeBytes": 0,
|
"sizeBytes": 0,
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"releasedAt": "2026-04-15T10:00:00Z",
|
"releasedAt": "2026-04-15T10:00:00Z",
|
||||||
"executable": "PROSERVE_UE_5_5.exe",
|
"executable": "PROSERVE_UE_5_5.exe",
|
||||||
"installFolderTemplate": "Proserve v{version}",
|
"installFolderTemplate": "PROSERVE v{version}",
|
||||||
"download": {
|
"download": {
|
||||||
"url": "https://asterionvr.com/PS_Launcher/builds/proserve-1.4.5.zip",
|
"url": "https://asterionvr.com/PS_Launcher/builds/proserve-1.4.5.zip",
|
||||||
"sizeBytes": 0,
|
"sizeBytes": 0,
|
||||||
|
|||||||
19
server/migrations/002_channel_betas.sql
Normal file
19
server/migrations/002_channel_betas.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- PS_Launcher schema v2
|
||||||
|
-- Ajoute deux flags par-license :
|
||||||
|
-- * channel : nom du manifest à servir (NULL = default = manifest/versions.json)
|
||||||
|
-- * can_see_betas : autorise la license à voir les versions taggées isBeta=true
|
||||||
|
--
|
||||||
|
-- À jouer après 001_init.sql.
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCE : les ALTER ci-dessous throw une exception sur OVH si la colonne /
|
||||||
|
-- l'index existe déjà. migrate.php attrape ces erreurs spécifiques ("Duplicate
|
||||||
|
-- column", "Duplicate key name") et continue. Tu peux rejouer le script sans
|
||||||
|
-- risque sur n'importe quelle DB (vide, partiellement migrée, complètement).
|
||||||
|
|
||||||
|
ALTER TABLE licenses ADD COLUMN channel VARCHAR(64) NULL AFTER max_machines;
|
||||||
|
ALTER TABLE licenses ADD COLUMN can_see_betas TINYINT(1) NOT NULL DEFAULT 0 AFTER channel;
|
||||||
|
ALTER TABLE licenses ADD INDEX idx_channel (channel);
|
||||||
|
|
||||||
|
-- Note : pas de migration de data. Les licenses existantes gardent
|
||||||
|
-- channel = NULL (= manifest default) et can_see_betas = 0 (= pas d'accès aux betas).
|
||||||
|
-- L'admin attribue manuellement les flags via la page licenses.php.
|
||||||
15
server/migrations/003_settings_lock.sql
Normal file
15
server/migrations/003_settings_lock.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- PS_Launcher schema v3
|
||||||
|
-- Ajoute settings_lock_password_hash sur la table licenses : permet à l'admin
|
||||||
|
-- de définir un mot de passe par-license qui verrouille la section "Avancés"
|
||||||
|
-- des Settings côté launcher. Le hash SHA-256 (hex lowercase) est renvoyé au
|
||||||
|
-- launcher dans la réponse signée de /license/validate, donc l'intégrité
|
||||||
|
-- bénéficie de la signature Ed25519 existante.
|
||||||
|
--
|
||||||
|
-- À jouer après 001_init.sql et 002_channel_betas.sql.
|
||||||
|
-- migrate.php attrape "Duplicate column" comme idempotent.
|
||||||
|
|
||||||
|
ALTER TABLE licenses ADD COLUMN settings_lock_password_hash CHAR(64) NULL AFTER can_see_betas;
|
||||||
|
|
||||||
|
-- Note : les licenses existantes ont settings_lock_password_hash = NULL → pas
|
||||||
|
-- de verrouillage côté launcher → comportement identique à avant. L'admin
|
||||||
|
-- attribue manuellement via le bouton 🔒 Lock dans licenses.php.
|
||||||
18
server/migrations/004_license_contact_emails.sql
Normal file
18
server/migrations/004_license_contact_emails.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- PS_Launcher schema v4
|
||||||
|
-- Ajoute contact_emails sur la table licenses : liste d'adresses séparées
|
||||||
|
-- par virgules ou retours-ligne (parsing tolérant côté admin), à utiliser
|
||||||
|
-- pour notifier les clients quand une nouvelle version PROSERVE est publiée.
|
||||||
|
--
|
||||||
|
-- Format de stockage : TEXT brut (pas JSON) — l'admin saisit librement, le
|
||||||
|
-- backend split sur [,;\n\r\s] et filtre par regex email avant l'envoi.
|
||||||
|
-- Pourquoi pas une table normalisée license_contacts(license_id, email) :
|
||||||
|
-- overkill pour une feature de notification opportuniste sans relations.
|
||||||
|
--
|
||||||
|
-- À jouer après 001_init.sql, 002_channel_betas.sql, 003_settings_lock.sql.
|
||||||
|
-- migrate.php attrape "Duplicate column" comme idempotent.
|
||||||
|
|
||||||
|
ALTER TABLE licenses ADD COLUMN contact_emails TEXT NULL AFTER settings_lock_password_hash;
|
||||||
|
|
||||||
|
-- Note : les licenses existantes ont contact_emails = NULL → aucun email
|
||||||
|
-- à notifier → comportement identique à avant. L'admin doit explicitement
|
||||||
|
-- attribuer les contacts via le modal d'édition license, onglet « Contacts ».
|
||||||
11
server/migrations/005_license_language.sql
Normal file
11
server/migrations/005_license_language.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- PS_Launcher schema v5
|
||||||
|
-- Ajoute language sur la table licenses : code de langue 2-letter (fr/en/zh/th/ar)
|
||||||
|
-- utilisé pour localiser les emails de notification de release. NULL = English
|
||||||
|
-- (defaut conservateur pour les licenses existantes qui n'ont pas été éditées).
|
||||||
|
-- Le contenu de la release note elle-même reste TOUJOURS en anglais — seul le
|
||||||
|
-- texte d'accompagnement (greeting, instructions, CTAs) est localisé.
|
||||||
|
--
|
||||||
|
-- À jouer après 004_license_contact_emails.sql.
|
||||||
|
-- migrate.php attrape "Duplicate column" comme idempotent.
|
||||||
|
|
||||||
|
ALTER TABLE licenses ADD COLUMN language VARCHAR(8) NULL AFTER contact_emails;
|
||||||
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**.
|
||||||
51
server/releasenotes/1.5.4.md
Normal file
51
server/releasenotes/1.5.4.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# PROSERVE v1.5.4
|
||||||
|
|
||||||
|
Built on **Unreal Engine 5.7** — major visual fidelity and runtime
|
||||||
|
performance improvements across all scenarios.
|
||||||
|
|
||||||
|
This release is packed with new tooling and a major rework of our AI
|
||||||
|
systems. Here's what's new:
|
||||||
|
|
||||||
|
## ✨ New features
|
||||||
|
|
||||||
|
- **PROSERVE Editor (v0.5 Alpha)** — first integrated editor for
|
||||||
|
building and tweaking scenarios directly inside PROSERVE.
|
||||||
|
*Still alpha — feedback welcome.*
|
||||||
|
|
||||||
|
- **AI Autonomous Agent — full rework** — smarter decision-making,
|
||||||
|
more believable behaviors, smoother pathfinding and reactions.
|
||||||
|
|
||||||
|
- **Conversational AI Agent (Alpha)** — talk to NPCs in natural
|
||||||
|
language. First iteration, expect rapid improvements over the
|
||||||
|
upcoming releases.
|
||||||
|
|
||||||
|
- **PS_Launcher** — new companion tool that simplifies version
|
||||||
|
updates and the launch of PROSERVE on each workstation. Auto-detects
|
||||||
|
new releases, handles install + redists, and surfaces stats and
|
||||||
|
documentation in one place.
|
||||||
|
Download: <https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe>
|
||||||
|
|
||||||
|
- **PS_Report** — view, export, and print your mission results and
|
||||||
|
trainee statistics directly from the launcher.
|
||||||
|
|
||||||
|
## 🚀 Gameplay improvements
|
||||||
|
|
||||||
|
- **Aiming** — reduced reaction time and improved shooting precision
|
||||||
|
across all firearms.
|
||||||
|
|
||||||
|
- **Character gaze** — characters now realistically track the
|
||||||
|
trainee and points of interest, adding presence and immersion to
|
||||||
|
every interaction.
|
||||||
|
|
||||||
|
- **Hose behavior (Firefighter edition)** — reworked physics and
|
||||||
|
grip feel for the firehose; more predictable, less jittery, more
|
||||||
|
satisfying to handle.
|
||||||
|
|
||||||
|
## 🌍 Localization
|
||||||
|
|
||||||
|
- **Thai language** added — full UI translation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*As always, your feedback is invaluable. If you spot any issue or
|
||||||
|
have suggestions, reach out to your ASTERION VR account manager.*
|
||||||
@@ -13,27 +13,183 @@ final class SignManifest
|
|||||||
public string $manifestPath;
|
public string $manifestPath;
|
||||||
public string $buildsDir;
|
public string $buildsDir;
|
||||||
public ?string $configPath;
|
public ?string $configPath;
|
||||||
|
public string $hashCachePath;
|
||||||
/** @var string[] */
|
/** @var string[] */
|
||||||
public array $log = [];
|
public array $log = [];
|
||||||
|
|
||||||
public function __construct(string $rootDir)
|
public function __construct(string $rootDir)
|
||||||
{
|
{
|
||||||
$this->manifestPath = $rootDir . '/manifest/versions.json';
|
$this->manifestPath = $rootDir . '/manifest/versions.json';
|
||||||
$this->buildsDir = $rootDir . '/builds';
|
$this->buildsDir = $rootDir . '/builds';
|
||||||
$this->configPath = $rootDir . '/api/config.php';
|
$this->configPath = $rootDir . '/api/config.php';
|
||||||
|
// Cache des hashs déjà calculés. Indexé par chemin absolu du ZIP, contient
|
||||||
|
// { size, mtime, sha256 } ; on recalcule uniquement si size ou mtime ont changé.
|
||||||
|
// Vit hors du répertoire web servi (sécurité : un utilisateur n'a aucune raison
|
||||||
|
// de pouvoir lire le cache).
|
||||||
|
$this->hashCachePath = $rootDir . '/manifest/.hashcache.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function out(string $line): void { $this->log[] = $line; }
|
private function out(string $line): void { $this->log[] = $line; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recompute sizes / sha256 for every version that has its ZIP uploaded,
|
* Lit le cache des hashs précalculés. Retourne un dictionnaire
|
||||||
* bump 'latest' to the highest signed version, then sign the manifest
|
* [path => ['size' => int, 'mtime' => int, 'sha256' => string]].
|
||||||
* with the Ed25519 private key from config.php (if available).
|
* @return array<string, array{size:int,mtime:int,sha256:string}>
|
||||||
|
*/
|
||||||
|
private function loadHashCache(): array
|
||||||
|
{
|
||||||
|
if (!is_file($this->hashCachePath)) return [];
|
||||||
|
$raw = @file_get_contents($this->hashCachePath);
|
||||||
|
if ($raw === false) return [];
|
||||||
|
$j = json_decode($raw, true);
|
||||||
|
return is_array($j) ? $j : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, array{size:int,mtime:int,sha256:string}> $cache */
|
||||||
|
private function saveHashCache(array $cache): void
|
||||||
|
{
|
||||||
|
@file_put_contents($this->hashCachePath, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule (ou retourne depuis le cache) le SHA-256 d'un fichier.
|
||||||
|
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
|
||||||
|
* ce qui permet de garder le cache à jour sans intervention manuelle.
|
||||||
*
|
*
|
||||||
|
* IMPORTANT : si <code>$force</code> est true, on IGNORE le cache et on
|
||||||
|
* recalcule (puis on rafraîchit le cache). Sans ça, le cache interne
|
||||||
|
* supplantait silencieusement le `--force` du caller — bug subtil parce
|
||||||
|
* que la couche outer de `run()` avait déjà skip le cache (sha REPLACE
|
||||||
|
* ou force=true), mais ce helper retombait sur sa propre cache lookup
|
||||||
|
* (clé = realpath) → retournait instantanément l'ancien hash sans
|
||||||
|
* recalculer, même après que l'opérateur ait re-uploadé le ZIP via
|
||||||
|
* SFTP avec mtime préservé.
|
||||||
|
*
|
||||||
|
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
|
||||||
|
* @return array{sha256:string, fromCache:bool, durationMs:int}
|
||||||
|
*/
|
||||||
|
private function getOrComputeSha256(string $path, array &$cache, bool $force = false): array
|
||||||
|
{
|
||||||
|
$size = filesize($path) ?: 0;
|
||||||
|
$mtime = filemtime($path) ?: 0;
|
||||||
|
$key = realpath($path) ?: $path;
|
||||||
|
|
||||||
|
if (!$force
|
||||||
|
&& isset($cache[$key])
|
||||||
|
&& ($cache[$key]['size'] ?? -1) === $size
|
||||||
|
&& ($cache[$key]['mtime'] ?? -1) === $mtime
|
||||||
|
&& !empty($cache[$key]['sha256'])) {
|
||||||
|
return ['sha256' => $cache[$key]['sha256'], 'fromCache' => true, 'durationMs' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
// Streamed hash en chunks 16 Mo. Deux bénéfices vs hash_file() atomique :
|
||||||
|
// 1. On peut envoyer un heartbeat (flush()) entre chunks pour que le
|
||||||
|
// proxy Apache/OVH ne timeout pas la requête pendant les ~5-10 min
|
||||||
|
// que prend un SHA-256 sur un ZIP de 14 Go via SAN mutualisé.
|
||||||
|
// 2. On peut relever set_time_limit() à chaque chunk (fenêtre glissante)
|
||||||
|
// au lieu de faire un unique set_time_limit(0) à la caller.
|
||||||
|
// Mémoire : hash_update_stream() ne buffere pas — c'est du streaming pur.
|
||||||
|
$fp = @fopen($path, 'rb');
|
||||||
|
if ($fp === false) {
|
||||||
|
return ['sha256' => '', 'fromCache' => false, 'durationMs' => 0];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$ctx = hash_init('sha256');
|
||||||
|
// 16 Mo = compromis entre nombre d'appels PHP et pression CPU par read()
|
||||||
|
$chunkBytes = 16 * 1024 * 1024;
|
||||||
|
while (!feof($fp)) {
|
||||||
|
hash_update_stream($ctx, $fp, $chunkBytes);
|
||||||
|
// Fenêtre glissante : autorise ~5 min de plus avant que PHP ne
|
||||||
|
// timeout. Sur un fichier de 14 Go / chunks 16 Mo = ~900 itérations,
|
||||||
|
// donc si un chunk prend >5 min c'est vraiment que le disque est HS.
|
||||||
|
@set_time_limit(300);
|
||||||
|
// Heartbeat côté front — évite Apache RequestTimeout / OVH proxy
|
||||||
|
// timeout sur les grosses requêtes. Silencieux si output buffering
|
||||||
|
// est actif (pas fatal).
|
||||||
|
@ob_flush();
|
||||||
|
@flush();
|
||||||
|
}
|
||||||
|
$sha = hash_final($ctx);
|
||||||
|
} finally {
|
||||||
|
fclose($fp);
|
||||||
|
}
|
||||||
|
$duration = (int)((microtime(true) - $start) * 1000);
|
||||||
|
|
||||||
|
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha];
|
||||||
|
return ['sha256' => $sha, 'fromCache' => false, 'durationMs' => $duration];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute sizes / sha256 selon le scope, puis re-signe le manifest avec Ed25519.
|
||||||
|
*
|
||||||
|
* Stratégie de hash :
|
||||||
|
* - Cache disque {$buildsDir}/.hashcache.json indexé par chemin → (size, mtime, sha256)
|
||||||
|
* - On re-hash seulement si la taille ou mtime du ZIP a changé (= upload de nouveau build)
|
||||||
|
* - Si le manifest contient déjà un sha256 valide ET que size+mtime n'ont pas changé,
|
||||||
|
* on évite carrément l'appel à hash_file()
|
||||||
|
* - Si une version a `download.hashAlgorithm = "none"` ou `download.skipHash = true`
|
||||||
|
* dans le manifest, on ne calcule pas le hash (sha256 reste à null/empty)
|
||||||
|
* - $force = true pour forcer un recalcul intégral (utile en cas de doute)
|
||||||
|
*
|
||||||
|
* Sur un mutualisé OVH avec 5 versions × 14 Go : avant ~25 min, après ~quelques secondes
|
||||||
|
* pour les versions inchangées + ~1 min par nouvelle version uploadée.
|
||||||
|
*
|
||||||
|
* @param string $scope 'all' (défaut) | 'versions' (ne touche que les Proserve) | 'launcher'
|
||||||
|
* @param bool $force Si true, ignore le cache et recalcule tous les hashs
|
||||||
|
* @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
|
||||||
|
* versions[] (les autres restent inchangées). Le scope 'launcher'
|
||||||
|
* est ignoré dans ce cas. ATTENTION : si plusieurs entrées
|
||||||
|
* partagent le même numéro de version (channels firefighter
|
||||||
|
* vs full sur 1.5.4.32 p.ex.), TOUTES sont hashées — d'où
|
||||||
|
* $onlyEntryId ci-dessous pour cibler une seule ligne.
|
||||||
|
* @param ?string $onlyEntryId Si non null, ne touche QUE l'entrée avec cet id (généré par
|
||||||
|
* generate_entry_id() au backoffice). Prend le pas sur
|
||||||
|
* $onlyVersion. Utilisé par le bouton « 🔁 Hash » d'une
|
||||||
|
* ligne isolée pour éviter de re-hasher les autres channels
|
||||||
|
* qui partagent le même numéro de version (2 × 14 Go dans une
|
||||||
|
* requête HTTP → risque de timeout front OVH).
|
||||||
* @return array{ok:bool, log:string[]}
|
* @return array{ok:bool, log:string[]}
|
||||||
*/
|
*/
|
||||||
public function run(): array
|
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null, ?string $onlyEntryId = null): array
|
||||||
{
|
{
|
||||||
|
// Sur OVH mutualisé, un SHA-256 d'un ZIP de 14 Go peut prendre 5-10 min via
|
||||||
|
// le SAN partagé. Le max_execution_time par défaut (30-60s) tue le process
|
||||||
|
// → Apache retourne 500 Internal Server Error avec le boilerplate
|
||||||
|
// postmaster@… — c'est le mode d'échec principal du bouton « Hash » au
|
||||||
|
// backoffice. On désactive la limite ici (couvre AUSSI les callers CLI et
|
||||||
|
// cron, pas seulement l'admin web). ignore_user_abort évite qu'un refresh
|
||||||
|
// ou une fermeture d'onglet côté opérateur interrompe un hash en cours.
|
||||||
|
@set_time_limit(0);
|
||||||
|
@ignore_user_abort(true);
|
||||||
|
|
||||||
|
// Capture toutes les erreurs PHP (warnings + fatals + exceptions non-catchées)
|
||||||
|
// dans un fichier accessible via SFTP, à côté du manifest. Sur OVH mutualisé
|
||||||
|
// les logs Apache ne sont accessibles que via le manager web — pas pratique
|
||||||
|
// pour un diagnostic rapide de 500. Ce fichier permet à l'opérateur de le
|
||||||
|
// grep post-clic sans passer par le manager.
|
||||||
|
$errorLogPath = dirname($this->manifestPath) . '/.signmanifest-error.log';
|
||||||
|
@ini_set('log_errors', '1');
|
||||||
|
@ini_set('error_log', $errorLogPath);
|
||||||
|
@error_reporting(E_ALL);
|
||||||
|
$ts = date('Y-m-d H:i:s');
|
||||||
|
@file_put_contents($errorLogPath,
|
||||||
|
"[{$ts}] --- SignManifest::run(scope={$scope}, force=" . ($force?'1':'0')
|
||||||
|
. ", onlyVersion=" . ($onlyVersion ?? 'null')
|
||||||
|
. ", onlyEntryId=" . ($onlyEntryId ?? 'null') . ") ---\n",
|
||||||
|
FILE_APPEND);
|
||||||
|
// Fatal errors → capturés par un shutdown handler. Sinon Apache renvoie
|
||||||
|
// juste 500 sans qu'on sache ce qui a claqué.
|
||||||
|
register_shutdown_function(function () use ($errorLogPath) {
|
||||||
|
$err = error_get_last();
|
||||||
|
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
|
||||||
|
@file_put_contents($errorLogPath,
|
||||||
|
"[" . date('Y-m-d H:i:s') . "] FATAL " . $err['type']
|
||||||
|
. " : {$err['message']} @ {$err['file']}:{$err['line']}\n",
|
||||||
|
FILE_APPEND);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!is_file($this->manifestPath)) {
|
if (!is_file($this->manifestPath)) {
|
||||||
$this->out("Manifest introuvable : {$this->manifestPath}");
|
$this->out("Manifest introuvable : {$this->manifestPath}");
|
||||||
return ['ok' => false, 'log' => $this->log];
|
return ['ok' => false, 'log' => $this->log];
|
||||||
@@ -45,9 +201,33 @@ final class SignManifest
|
|||||||
return ['ok' => false, 'log' => $this->log];
|
return ['ok' => false, 'log' => $this->log];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$doVersions = ($scope === 'all' || $scope === 'versions');
|
||||||
|
$doLauncher = ($scope === 'all' || $scope === 'launcher');
|
||||||
|
if ($onlyVersion !== null || $onlyEntryId !== null) {
|
||||||
|
// Mode "hash une seule entrée / version" : on ne touche pas au launcher,
|
||||||
|
// et on ne hash que ce qui correspond au filtre demandé dans versions[].
|
||||||
|
$doLauncher = false;
|
||||||
|
$doVersions = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charge le cache des hashs (indexé par chemin réel du ZIP)
|
||||||
|
$cache = $this->loadHashCache();
|
||||||
|
$cacheChanged = false;
|
||||||
|
|
||||||
$hashedVersions = [];
|
$hashedVersions = [];
|
||||||
foreach ($manifest['versions'] as &$v) {
|
if ($doVersions) foreach ($manifest['versions'] as &$v) {
|
||||||
$version = $v['version'] ?? '?';
|
$version = $v['version'] ?? '?';
|
||||||
|
$entryId = (string)($v['id'] ?? '');
|
||||||
|
// Filtre par id d'entrée prioritaire sur filtre par version (cas
|
||||||
|
// channels multiples partageant un numéro de version identique —
|
||||||
|
// ex : proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32).
|
||||||
|
// Sinon, filtre par numéro de version (rétro-compat).
|
||||||
|
if ($onlyEntryId !== null && $entryId !== $onlyEntryId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($onlyEntryId === null && $onlyVersion !== null && $version !== $onlyVersion) {
|
||||||
|
continue; // skip silently les autres versions
|
||||||
|
}
|
||||||
$url = $v['download']['url'] ?? '';
|
$url = $v['download']['url'] ?? '';
|
||||||
if ($url === '') {
|
if ($url === '') {
|
||||||
$this->out(" [skip] $version : pas d'URL dans le manifest");
|
$this->out(" [skip] $version : pas d'URL dans le manifest");
|
||||||
@@ -72,19 +252,71 @@ final class SignManifest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$size = filesize($zip);
|
$size = filesize($zip) ?: 0;
|
||||||
$sha = hash_file('sha256', $zip);
|
|
||||||
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$sha}");
|
|
||||||
|
|
||||||
$v['download']['sizeBytes'] = $size;
|
$v['download']['sizeBytes'] = $size;
|
||||||
$v['download']['sha256'] = $sha;
|
|
||||||
|
// Hash skipping : per-version flag dans le manifest. Si "none" ou skipHash=true,
|
||||||
|
// on neutralise le sha256 et la vérif côté client est passée. Utile pour les builds
|
||||||
|
// internes ou les très gros ZIPs où on accepte le compromis perf/sécurité (la
|
||||||
|
// signature Ed25519 du manifest reste). À utiliser avec parcimonie.
|
||||||
|
$algo = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256'));
|
||||||
|
$skipFlag = !empty($v['download']['skipHash']);
|
||||||
|
if ($algo === 'none' || $skipFlag) {
|
||||||
|
$v['download']['sha256'] = '';
|
||||||
|
$v['download']['hashAlgorithm'] = 'none';
|
||||||
|
$this->out(" [skip-hash] $version : " . basename($zip) . " ($size octets) — hash non calculé (hashAlgorithm=none)");
|
||||||
|
$hashedVersions[] = $version;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache lookup (sauf si --force)
|
||||||
|
if (!$force) {
|
||||||
|
$key = realpath($zip) ?: $zip;
|
||||||
|
$existingSha = (string)($v['download']['sha256'] ?? '');
|
||||||
|
$hasValidSha = $existingSha !== ''
|
||||||
|
&& !str_starts_with($existingSha, 'REPLACE');
|
||||||
|
if ($hasValidSha
|
||||||
|
&& isset($cache[$key])
|
||||||
|
&& ($cache[$key]['size'] ?? -1) === $size
|
||||||
|
&& ($cache[$key]['mtime'] ?? -1) === (filemtime($zip) ?: 0)
|
||||||
|
&& ($cache[$key]['sha256'] ?? '') === $existingSha) {
|
||||||
|
$this->out(" [cache] $version : " . basename($zip) . " ($size octets) — sha256 inchangé, hash cache hit");
|
||||||
|
$hashedVersions[] = $version;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log préalable au hash — utile pour identifier QUEL ZIP a fait
|
||||||
|
// planter le process si on retombe sur le 500. Sans ça, l'error log
|
||||||
|
// dit juste "PHP Fatal…" sans savoir si c'est le 1er ou le Nième ZIP.
|
||||||
|
@file_put_contents($errorLogPath,
|
||||||
|
"[" . date('Y-m-d H:i:s') . "] START hash $version → $zip ($size octets)\n",
|
||||||
|
FILE_APPEND);
|
||||||
|
try {
|
||||||
|
$r = $this->getOrComputeSha256($zip, $cache, $force);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
@file_put_contents($errorLogPath,
|
||||||
|
"[" . date('Y-m-d H:i:s') . "] EXCEPTION during hash of $version : "
|
||||||
|
. get_class($e) . " : {$e->getMessage()} @ {$e->getFile()}:{$e->getLine()}\n"
|
||||||
|
. $e->getTraceAsString() . "\n",
|
||||||
|
FILE_APPEND);
|
||||||
|
$this->out(" [ERROR] $version : hash failed — " . $e->getMessage());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
@file_put_contents($errorLogPath,
|
||||||
|
"[" . date('Y-m-d H:i:s') . "] DONE hash $version → sha256={$r['sha256']} ({$r['durationMs']} ms, fromCache=" . ($r['fromCache']?'1':'0') . ")\n",
|
||||||
|
FILE_APPEND);
|
||||||
|
$cacheChanged = true;
|
||||||
|
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||||||
|
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
|
||||||
|
$v['download']['sha256'] = $r['sha256'];
|
||||||
$hashedVersions[] = $version;
|
$hashedVersions[] = $version;
|
||||||
}
|
}
|
||||||
unset($v);
|
unset($v);
|
||||||
|
|
||||||
// Section launcher : si présente, on tente de hasher le PSLauncher-{ver}.exe
|
// Section launcher : si présente, on tente de hasher le PSLauncher-{ver}.exe
|
||||||
// dans builds/launcher/. On ignore proprement si l'exe n'est pas là.
|
// dans builds/launcher/. On ignore proprement si l'exe n'est pas là.
|
||||||
if (isset($manifest['launcher']) && is_array($manifest['launcher'])) {
|
if ($doLauncher && isset($manifest['launcher']) && is_array($manifest['launcher'])) {
|
||||||
$launcher = &$manifest['launcher'];
|
$launcher = &$manifest['launcher'];
|
||||||
$lver = $launcher['version'] ?? '';
|
$lver = $launcher['version'] ?? '';
|
||||||
$lurl = $launcher['download']['url'] ?? '';
|
$lurl = $launcher['download']['url'] ?? '';
|
||||||
@@ -97,11 +329,27 @@ final class SignManifest
|
|||||||
if (count($candidates) === 1) $lpath = $candidates[0];
|
if (count($candidates) === 1) $lpath = $candidates[0];
|
||||||
}
|
}
|
||||||
if (is_file($lpath)) {
|
if (is_file($lpath)) {
|
||||||
$lsize = filesize($lpath);
|
$lsize = filesize($lpath) ?: 0;
|
||||||
$lsha = hash_file('sha256', $lpath);
|
|
||||||
$launcher['download']['sizeBytes'] = $lsize;
|
$launcher['download']['sizeBytes'] = $lsize;
|
||||||
$launcher['download']['sha256'] = $lsha;
|
|
||||||
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) sha256={$lsha}");
|
// Cache lookup pour le launcher exe aussi
|
||||||
|
$lkey = realpath($lpath) ?: $lpath;
|
||||||
|
$existingLsha = (string)($launcher['download']['sha256'] ?? '');
|
||||||
|
$hasValidLsha = $existingLsha !== '' && !str_starts_with($existingLsha, 'REPLACE');
|
||||||
|
if (!$force
|
||||||
|
&& $hasValidLsha
|
||||||
|
&& isset($cache[$lkey])
|
||||||
|
&& ($cache[$lkey]['size'] ?? -1) === $lsize
|
||||||
|
&& ($cache[$lkey]['mtime'] ?? -1) === (filemtime($lpath) ?: 0)
|
||||||
|
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
|
||||||
|
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
|
||||||
|
} else {
|
||||||
|
$r = $this->getOrComputeSha256($lpath, $cache, $force);
|
||||||
|
$cacheChanged = true;
|
||||||
|
$launcher['download']['sha256'] = $r['sha256'];
|
||||||
|
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
|
||||||
|
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) sha256={$r['sha256']} $note");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->out(" [launcher] v{$lver} : exe introuvable dans builds/launcher/");
|
$this->out(" [launcher] v{$lver} : exe introuvable dans builds/launcher/");
|
||||||
}
|
}
|
||||||
@@ -109,13 +357,25 @@ final class SignManifest
|
|||||||
unset($launcher);
|
unset($launcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bump auto de `latest` sur la plus haute version effectivement uploadée
|
// Bump auto de `latest` : prend la plus haute version dispo (sha256 valide OU
|
||||||
if (!empty($hashedVersions)) {
|
// hashAlgorithm=none) parmi TOUTES les entrées du manifest, pas seulement celles
|
||||||
usort($hashedVersions, 'version_compare');
|
// qu'on a re-hashé sur ce run. Évite de redescendre `latest` quand on hash une
|
||||||
$newLatest = end($hashedVersions);
|
// ancienne version isolée via $onlyVersion.
|
||||||
if (($manifest['latest'] ?? null) !== $newLatest) {
|
if ($doVersions) {
|
||||||
$this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}");
|
$eligible = [];
|
||||||
$manifest['latest'] = $newLatest;
|
foreach ($manifest['versions'] as $vEntry) {
|
||||||
|
$sha = (string)($vEntry['download']['sha256'] ?? '');
|
||||||
|
$algo = strtolower((string)($vEntry['download']['hashAlgorithm'] ?? 'sha256'));
|
||||||
|
$hasValid = ($sha !== '' && !str_starts_with($sha, 'REPLACE')) || $algo === 'none';
|
||||||
|
if ($hasValid) $eligible[] = $vEntry['version'];
|
||||||
|
}
|
||||||
|
if (!empty($eligible)) {
|
||||||
|
usort($eligible, 'version_compare');
|
||||||
|
$newLatest = end($eligible);
|
||||||
|
if (($manifest['latest'] ?? null) !== $newLatest) {
|
||||||
|
$this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}");
|
||||||
|
$manifest['latest'] = $newLatest;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +405,15 @@ final class SignManifest
|
|||||||
return ['ok' => false, 'log' => $this->log];
|
return ['ok' => false, 'log' => $this->log];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persiste le cache de hashs si on l'a touché. On nettoie aussi les entrées
|
||||||
|
// obsolètes (fichiers supprimés) pour éviter qu'il grossisse indéfiniment.
|
||||||
|
if ($cacheChanged) {
|
||||||
|
foreach (array_keys($cache) as $cachedPath) {
|
||||||
|
if (!is_file($cachedPath)) unset($cache[$cachedPath]);
|
||||||
|
}
|
||||||
|
$this->saveHashCache($cache);
|
||||||
|
}
|
||||||
|
|
||||||
$this->out("Manifest mis à jour (" . count($hashedVersions) . " version(s)).");
|
$this->out("Manifest mis à jour (" . count($hashedVersions) . " version(s)).");
|
||||||
return ['ok' => true, 'log' => $this->log];
|
return ['ok' => true, 'log' => $this->log];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,13 +70,42 @@ foreach ($files as $f) {
|
|||||||
// Découpe naïve sur les ';' suivis d'un saut de ligne (suffit pour notre schéma)
|
// Découpe naïve sur les ';' suivis d'un saut de ligne (suffit pour notre schéma)
|
||||||
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: []));
|
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: []));
|
||||||
foreach ($statements as $stmt) {
|
foreach ($statements as $stmt) {
|
||||||
if ($stmt === '' || str_starts_with($stmt, '--')) continue;
|
// Strip les lignes de commentaire SQL (--) du début / intercalées avant
|
||||||
|
// de checker si le statement est vide. Sinon : un fichier migration qui
|
||||||
|
// commence par un header de commentaires se retrouve dans le PREMIER
|
||||||
|
// chunk avec son premier ALTER (séparés par \n pas par ;\n) ; le chunk
|
||||||
|
// commence donc par `--`, le check str_starts_with le déclare vide,
|
||||||
|
// et l'ALTER est silencieusement skip. Bug rapporté : migrations 003+
|
||||||
|
// qui n'ont qu'un seul ALTER après un bloc de commentaires en tête
|
||||||
|
// n'étaient jamais appliquées (output vide entre "--- 003 ---" et "===").
|
||||||
|
$lines = preg_split('/\r?\n/', $stmt) ?: [];
|
||||||
|
$kept = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$t = ltrim($line);
|
||||||
|
if ($t === '' || str_starts_with($t, '--')) continue;
|
||||||
|
$kept[] = $line;
|
||||||
|
}
|
||||||
|
$stmt = trim(implode("\n", $kept));
|
||||||
|
if ($stmt === '') continue;
|
||||||
try {
|
try {
|
||||||
$pdo->exec($stmt);
|
$pdo->exec($stmt);
|
||||||
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);
|
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);
|
||||||
out(" ✔ " . $first40 . "…");
|
out(" ✔ " . $first40 . "…");
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
out(" ✘ ERREUR : " . $e->getMessage());
|
$msg = $e->getMessage();
|
||||||
|
// Tolérance idempotence : on n'aboie pas sur les ALTER qui re-touchent
|
||||||
|
// une colonne / un index existant. Permet de rejouer migrate.php sans
|
||||||
|
// risque sur une DB déjà partiellement / complètement migrée.
|
||||||
|
$idempotent = str_contains($msg, 'Duplicate column')
|
||||||
|
|| str_contains($msg, 'Duplicate key name')
|
||||||
|
|| str_contains($msg, "Can't DROP") // ALTER DROP COLUMN inexistante
|
||||||
|
|| str_contains($msg, 'check that column/key exists');
|
||||||
|
if ($idempotent) {
|
||||||
|
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);
|
||||||
|
out(" ↺ (déjà appliqué) " . $first40 . "…");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out(" ✘ ERREUR : " . $msg);
|
||||||
out(" Sur : " . substr($stmt, 0, 80) . "…");
|
out(" Sur : " . substr($stmt, 0, 80) . "…");
|
||||||
exit(3);
|
exit(3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
/**
|
/**
|
||||||
* Wrapper CLI pour PSLauncher\Tools\SignManifest.
|
* Wrapper CLI pour PSLauncher\Tools\SignManifest.
|
||||||
*
|
*
|
||||||
* Usage : cd ~/www/PS_Launcher && php tools/sign-manifest.php
|
* Usage :
|
||||||
|
* cd ~/www/PS_Launcher && php tools/sign-manifest.php
|
||||||
|
* php tools/sign-manifest.php --scope=launcher # ne re-signe que la section launcher
|
||||||
|
* php tools/sign-manifest.php --scope=versions # ne re-signe que les builds Proserve
|
||||||
|
* php tools/sign-manifest.php --force # ignore le cache, recalcule tous les SHA-256
|
||||||
*
|
*
|
||||||
* Le backoffice admin appelle directement la classe (pas d'exec).
|
* Le backoffice admin appelle directement la classe (pas d'exec).
|
||||||
*/
|
*/
|
||||||
@@ -10,7 +14,15 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require __DIR__ . '/SignManifest.php';
|
require __DIR__ . '/SignManifest.php';
|
||||||
|
|
||||||
|
$scope = 'all';
|
||||||
|
$force = false;
|
||||||
|
foreach (array_slice($argv, 1) as $arg) {
|
||||||
|
if ($arg === '--force' || $arg === '-f') $force = true;
|
||||||
|
elseif (str_starts_with($arg, '--scope=')) $scope = substr($arg, 8);
|
||||||
|
elseif (in_array($arg, ['versions', 'launcher', 'all'], true)) $scope = $arg;
|
||||||
|
}
|
||||||
|
|
||||||
$signer = new \PSLauncher\Tools\SignManifest(dirname(__DIR__));
|
$signer = new \PSLauncher\Tools\SignManifest(dirname(__DIR__));
|
||||||
$result = $signer->run();
|
$result = $signer->run($scope, $force);
|
||||||
foreach ($result['log'] as $line) echo $line . "\n";
|
foreach ($result['log'] as $line) echo $line . "\n";
|
||||||
exit($result['ok'] ? 0 : 1);
|
exit($result['ok'] ? 0 : 1);
|
||||||
|
|||||||
BIN
src/Logo ASTERION - Small - White.png
Normal file
BIN
src/Logo ASTERION - Small - White.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
@@ -1,6 +1,7 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -13,8 +14,18 @@ using PSLauncher.Core.Downloads;
|
|||||||
using PSLauncher.Core.Installations;
|
using PSLauncher.Core.Installations;
|
||||||
using PSLauncher.Core.Integrity;
|
using PSLauncher.Core.Integrity;
|
||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Core.Manifests;
|
using PSLauncher.Core.Manifests;
|
||||||
|
using PSLauncher.Core.Security;
|
||||||
|
using PSLauncher.Core.ApiTool;
|
||||||
|
using PSLauncher.Core.DocTool;
|
||||||
|
using PSLauncher.Core.Lan;
|
||||||
|
using PSLauncher.Core.Health;
|
||||||
|
using PSLauncher.Core.Health.OpenVRInterop;
|
||||||
|
using PSLauncher.Core.Migrations;
|
||||||
using PSLauncher.Core.Process;
|
using PSLauncher.Core.Process;
|
||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
|
using PSLauncher.Core.SteamVr;
|
||||||
using PSLauncher.Core.Updates;
|
using PSLauncher.Core.Updates;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -25,18 +36,77 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
private IHost? _host;
|
private IHost? _host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IServiceProvider du host DI. Exposé pour les rares cas où une vue est
|
||||||
|
/// instanciée par <c>new</c> hors du container (ex: SettingsDialog) et a
|
||||||
|
/// besoin de résoudre un service.
|
||||||
|
/// </summary>
|
||||||
|
public IServiceProvider? HostServices => _host?.Services;
|
||||||
|
private static System.Threading.Mutex? _singleInstanceMutex;
|
||||||
|
|
||||||
|
private const string SingleInstanceMutexName = "Global\\PSLauncher-3F8E2C1A-9B47-4D5E-A0F8-2E9D1B6C7A3F";
|
||||||
|
private const string BringToFrontMessage = "PSLAUNCHER_BRING_TO_FRONT";
|
||||||
|
private static readonly int WM_PSLAUNCHER_BRING = RegisterWindowMessage(BringToFrontMessage);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern int RegisterWindowMessage(string lpString);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool PostMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
|
private const int HWND_BROADCAST = 0xFFFF;
|
||||||
|
|
||||||
public static string LogsDirectory { get; private set; } = string.Empty;
|
public static string LogsDirectory { get; private set; } = string.Empty;
|
||||||
|
|
||||||
protected override void OnStartup(StartupEventArgs e)
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
|
||||||
|
// Single-instance : si une autre instance tourne déjà, on lui demande de
|
||||||
|
// se mettre au premier plan via un broadcast Windows et on quitte tout de suite.
|
||||||
|
_singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true,
|
||||||
|
name: SingleInstanceMutexName, out var createdNew);
|
||||||
|
if (!createdNew)
|
||||||
|
{
|
||||||
|
PostMessage((IntPtr)HWND_BROADCAST, WM_PSLAUNCHER_BRING, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
Shutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Logs avant tout — pour ne rien perdre des erreurs au boot
|
// Logs avant tout — pour ne rien perdre des erreurs au boot
|
||||||
LogsDirectory = Path.Combine(
|
LogsDirectory = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
"PSLauncher", "logs");
|
"PSLauncher", "logs");
|
||||||
Directory.CreateDirectory(LogsDirectory);
|
Directory.CreateDirectory(LogsDirectory);
|
||||||
|
|
||||||
|
// WebView2 a besoin d'écrire son UserDataFolder (cache, cookies, GPU shader cache,
|
||||||
|
// etc.). Par défaut il essaie à côté du .exe — ce qui échoue silencieusement quand
|
||||||
|
// on est installé en Program Files (read-only sans admin) → onglets Report/Doc
|
||||||
|
// vides. On force le user-data-folder dans %LocalAppData%\PSLauncher\WebView2 via
|
||||||
|
// la var d'environnement officielle, lue par WebView2 à la première initialisation.
|
||||||
|
// À setter AVANT que tout contrôle WebView2 ne soit instancié → ici, dans OnStartup,
|
||||||
|
// est largement assez tôt (la MainWindow n'est créée que plus bas).
|
||||||
|
var webViewDataDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"PSLauncher", "WebView2");
|
||||||
|
Directory.CreateDirectory(webViewDataDir);
|
||||||
|
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", webViewDataDir, EnvironmentVariableTarget.Process);
|
||||||
|
|
||||||
|
// Bootstrap i18n : on lit d'abord la config (sans services DI) pour fixer la
|
||||||
|
// langue avant que le moindre élément XAML soit instancié.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bootstrapStore = new ConfigStore(
|
||||||
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<ConfigStore>.Instance);
|
||||||
|
var bootstrapCfg = bootstrapStore.Load();
|
||||||
|
Strings.Init(bootstrapCfg.Language);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
Strings.Init("auto");
|
||||||
|
}
|
||||||
|
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.MinimumLevel.Debug()
|
.MinimumLevel.Debug()
|
||||||
.Enrich.FromLogContext()
|
.Enrich.FromLogContext()
|
||||||
@@ -50,8 +120,33 @@ public partial class App : Application
|
|||||||
|
|
||||||
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
|
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
|
||||||
|
|
||||||
|
// RENDER MODE : sur certains environnements (RDP, VMs sans hardware accel,
|
||||||
|
// GPU émulé, Intel HD anciens, Windows Server sans Desktop Experience…)
|
||||||
|
// l'accélération matérielle WPF plante en silence : la fenêtre s'affiche
|
||||||
|
// toute blanche, aucun contenu ne rend, aucune exception ne remonte. C'est
|
||||||
|
// un bug classique non rattrapable côté .NET parce que le pipeline graphique
|
||||||
|
// tombe entre les mailles du try/catch. On force le rendu software dans 3 cas :
|
||||||
|
// 1. RenderCapability.Tier == 0 (WPF lui-même détecte aucune accel possible)
|
||||||
|
// 2. Session Bureau à distance (RDP) détectée
|
||||||
|
// 3. Var d'env PSLAUNCHER_SOFTWARE_RENDER=1 (override manuel pour debug)
|
||||||
|
// Trade-off : rendu software plus lent (~30% impact sur le scrolling de la
|
||||||
|
// liste des versions), mais au moins ça affiche QUELQUE CHOSE.
|
||||||
|
ApplyRenderModeFallback();
|
||||||
|
|
||||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||||
Log.Fatal((Exception)args.ExceptionObject, "Unhandled exception (AppDomain)");
|
{
|
||||||
|
// CRITIQUE : on flush AVANT que le process meure. Sans ça, sur les
|
||||||
|
// exceptions de corruption d'état (AccessViolation depuis P/Invoke,
|
||||||
|
// SEH natif…) le buffer Serilog n'est jamais écrit sur disque, et
|
||||||
|
// le crash est totalement invisible dans les logs — exactement le
|
||||||
|
// mode de panne qu'on a eu avec OpenVR pendant le démarrage SteamVR.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Log.Fatal((Exception)args.ExceptionObject, "Unhandled exception (AppDomain) IsTerminating={IsTerminating}", args.IsTerminating);
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
}
|
||||||
|
catch { /* dernier recours — on ne peut plus rien faire */ }
|
||||||
|
};
|
||||||
DispatcherUnhandledException += (_, args) =>
|
DispatcherUnhandledException += (_, args) =>
|
||||||
{
|
{
|
||||||
Log.Error(args.Exception, "Unhandled UI exception");
|
Log.Error(args.Exception, "Unhandled UI exception");
|
||||||
@@ -67,13 +162,25 @@ public partial class App : Application
|
|||||||
|
|
||||||
services.AddSingleton(sp =>
|
services.AddSingleton(sp =>
|
||||||
{
|
{
|
||||||
var http = new HttpClient(new SocketsHttpHandler
|
// Handler tuné pour les gros téléchargements parallèles :
|
||||||
|
// - MaxConnectionsPerServer=32 pour aligner avec le clamp DownloadManager (8-32).
|
||||||
|
// Default WPF = 8 → trop bas pour notre multi-segment.
|
||||||
|
// - AutomaticDecompression=None : les builds sont des ZIPs déjà compressés ;
|
||||||
|
// éviter la CPU+RAM gaspillée par un éventuel wrapping gzip côté serveur.
|
||||||
|
// - PooledConnectionLifetime court pour éviter qu'OVH ferme nos sockets sous nous.
|
||||||
|
var handler = new SocketsHttpHandler
|
||||||
{
|
{
|
||||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||||
AutomaticDecompression = System.Net.DecompressionMethods.All
|
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
|
||||||
})
|
MaxConnectionsPerServer = 32,
|
||||||
|
EnableMultipleHttp2Connections = true,
|
||||||
|
AutomaticDecompression = System.Net.DecompressionMethods.None,
|
||||||
|
};
|
||||||
|
var http = new HttpClient(handler)
|
||||||
{
|
{
|
||||||
Timeout = Timeout.InfiniteTimeSpan
|
Timeout = Timeout.InfiniteTimeSpan,
|
||||||
|
DefaultRequestVersion = System.Net.HttpVersion.Version20,
|
||||||
|
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
|
||||||
};
|
};
|
||||||
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PSLauncher", "0.5"));
|
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PSLauncher", "0.5"));
|
||||||
return http;
|
return http;
|
||||||
@@ -89,10 +196,20 @@ public partial class App : Application
|
|||||||
services.AddSingleton<IZipInstaller, ZipInstaller>();
|
services.AddSingleton<IZipInstaller, ZipInstaller>();
|
||||||
services.AddSingleton<IDownloadStateStore, DownloadStateStore>();
|
services.AddSingleton<IDownloadStateStore, DownloadStateStore>();
|
||||||
|
|
||||||
|
services.AddSingleton<IManifestCache, ManifestCache>();
|
||||||
|
services.AddSingleton<ISettingsLockService, SettingsLockService>();
|
||||||
services.AddSingleton<IManifestService>(sp =>
|
services.AddSingleton<IManifestService>(sp =>
|
||||||
new ManifestService(
|
new ManifestService(
|
||||||
sp.GetRequiredService<HttpClient>(),
|
sp.GetRequiredService<HttpClient>(),
|
||||||
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
||||||
|
// Channel provider : lit le channel depuis la license cachée à
|
||||||
|
// chaque fetch (sp.GetRequiredService est lazy, donc pas de
|
||||||
|
// dépendance circulaire avec ILicenseService au démarrage).
|
||||||
|
// Si pas de license cachée → null → manifest default.
|
||||||
|
() => sp.GetRequiredService<ILicenseService>().GetCached()?.Channel,
|
||||||
|
sp.GetRequiredService<IManifestCache>(),
|
||||||
|
sp.GetRequiredService<IPeerManifestFetcher>(),
|
||||||
|
sp.GetRequiredService<ISettingsLockService>(),
|
||||||
sp.GetRequiredService<ILogger<ManifestService>>()));
|
sp.GetRequiredService<ILogger<ManifestService>>()));
|
||||||
|
|
||||||
services.AddSingleton<IDownloadManager, DownloadManager>();
|
services.AddSingleton<IDownloadManager, DownloadManager>();
|
||||||
@@ -105,25 +222,175 @@ public partial class App : Application
|
|||||||
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
|
||||||
sp.GetRequiredService<IConfigStore>(),
|
sp.GetRequiredService<IConfigStore>(),
|
||||||
sp.GetRequiredService<LocalConfig>(),
|
sp.GetRequiredService<LocalConfig>(),
|
||||||
|
sp.GetRequiredService<ISettingsLockService>(),
|
||||||
sp.GetRequiredService<ILogger<LicenseService>>()));
|
sp.GetRequiredService<ILogger<LicenseService>>()));
|
||||||
|
|
||||||
services.AddSingleton<IToastService, ToastService>();
|
services.AddSingleton<IToastService, ToastService>();
|
||||||
|
|
||||||
|
// 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>>()));
|
||||||
|
|
||||||
|
// Déploiement de l'outil Documentation (parallèle à Report) :
|
||||||
|
// copie atomique de _doc/ → C:\xampp\htdocs\ProserveDoc\
|
||||||
|
services.AddSingleton<IDocToolDeployer>(sp =>
|
||||||
|
new DocToolDeployer(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().DocTool,
|
||||||
|
sp.GetRequiredService<ILogger<DocToolDeployer>>()));
|
||||||
|
|
||||||
|
// Déploiement de l'API PHP de stats (parallèle à Report/Doc) :
|
||||||
|
// copie atomique de _api/ → C:\xampp\htdocs\ProserveApi\
|
||||||
|
services.AddSingleton<IApiToolDeployer>(sp =>
|
||||||
|
new ApiToolDeployer(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
|
||||||
|
sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
|
||||||
|
|
||||||
|
// Merge des settings SteamVR : si _steamvr/steamvr.vrsettings est
|
||||||
|
// présent dans le ZIP, tue SteamVR + Vive Business Streaming et
|
||||||
|
// fusionne les blocs racine dans le fichier de l'utilisateur
|
||||||
|
// (replace si existe, ajoute sinon). Voir SteamVrSettingsDeployer.
|
||||||
|
services.AddSingleton<ISteamVrSettingsDeployer>(sp =>
|
||||||
|
new SteamVrSettingsDeployer(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().SteamVr,
|
||||||
|
sp.GetRequiredService<ILogger<SteamVrSettingsDeployer>>()));
|
||||||
|
|
||||||
|
// Cache LAN P2P : permet aux PCs du LAN de partager les ZIPs déjà
|
||||||
|
// téléchargés au lieu de re-télécharger 14 Go depuis OVH.
|
||||||
|
// - ZipCacheStore : couche de persistence (downloads + sidecar SHA)
|
||||||
|
// - LanDiscoveryService : auto-découverte UDP des peers
|
||||||
|
// - LanCacheServer : serveur HTTP local qui sert les ZIPs cachés
|
||||||
|
// - PeerSourceResolver : avant chaque DL, probe les peers et choisit
|
||||||
|
// le premier qui matche le SHA-256 attendu
|
||||||
|
services.AddSingleton<IZipCacheStore, ZipCacheStore>();
|
||||||
|
services.AddSingleton<LanDiscoveryService>(sp => new LanDiscoveryService(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
|
||||||
|
cache: sp.GetRequiredService<IZipCacheStore>(),
|
||||||
|
configStore: sp.GetRequiredService<IConfigStore>(),
|
||||||
|
http: sp.GetRequiredService<HttpClient>(),
|
||||||
|
logger: sp.GetRequiredService<ILogger<LanDiscoveryService>>()));
|
||||||
|
services.AddSingleton<ILanDiscoveryService>(sp => sp.GetRequiredService<LanDiscoveryService>());
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<LanDiscoveryService>());
|
||||||
|
services.AddSingleton<LanCacheServer>(sp => new LanCacheServer(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
|
||||||
|
cache: sp.GetRequiredService<IZipCacheStore>(),
|
||||||
|
manifestCache: sp.GetRequiredService<IManifestCache>(),
|
||||||
|
logger: sp.GetRequiredService<ILogger<LanCacheServer>>()));
|
||||||
|
services.AddSingleton<ILanCacheServer>(sp => sp.GetRequiredService<LanCacheServer>());
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<LanCacheServer>());
|
||||||
|
services.AddSingleton<IPeerSourceResolver>(sp => new PeerSourceResolver(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
|
||||||
|
discovery: sp.GetRequiredService<ILanDiscoveryService>(),
|
||||||
|
http: sp.GetRequiredService<HttpClient>(),
|
||||||
|
logger: sp.GetRequiredService<ILogger<PeerSourceResolver>>()));
|
||||||
|
services.AddSingleton<IPeerManifestFetcher>(sp => new PeerManifestFetcher(
|
||||||
|
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
|
||||||
|
discovery: sp.GetRequiredService<ILanDiscoveryService>(),
|
||||||
|
http: sp.GetRequiredService<HttpClient>(),
|
||||||
|
logger: sp.GetRequiredService<ILogger<PeerManifestFetcher>>()));
|
||||||
|
|
||||||
|
services.AddSingleton<IOpenVrService, OpenVrService>();
|
||||||
|
services.AddSingleton<ISystemHealthService, SystemHealthService>();
|
||||||
|
|
||||||
services.AddTransient<SettingsViewModel>();
|
services.AddTransient<SettingsViewModel>();
|
||||||
services.AddSingleton<MainViewModel>();
|
services.AddSingleton<MainViewModel>();
|
||||||
services.AddSingleton<MainWindow>();
|
services.AddSingleton<MainWindow>();
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
// Démarre les hosted services (LanCacheServer, LanDiscoveryService, …).
|
||||||
|
// Sans ce StartAsync, les BackgroundService.ExecuteAsync ne seraient
|
||||||
|
// jamais appelés — le DI les construit mais ne les "réveille" pas.
|
||||||
|
// Fire-and-forget : on ne bloque pas le UI thread, mais on garde l'erreur
|
||||||
|
// dans le log si un service plante au démarrage.
|
||||||
|
_ = _host.StartAsync().ContinueWith(t =>
|
||||||
|
{
|
||||||
|
if (t.IsFaulted) Log.Error(t.Exception, "Host StartAsync failed");
|
||||||
|
}, TaskScheduler.Default);
|
||||||
|
|
||||||
var window = _host.Services.GetRequiredService<MainWindow>();
|
var window = _host.Services.GetRequiredService<MainWindow>();
|
||||||
window.DataContext = _host.Services.GetRequiredService<MainViewModel>();
|
window.DataContext = _host.Services.GetRequiredService<MainViewModel>();
|
||||||
|
// Mise en page droite-à-gauche pour les langues RTL (arabe).
|
||||||
|
if (Strings.IsRightToLeft)
|
||||||
|
window.FlowDirection = FlowDirection.RightToLeft;
|
||||||
MainWindow = window;
|
MainWindow = window;
|
||||||
window.Show();
|
window.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Décide entre rendu hardware (par défaut WPF) et rendu software, en fonction
|
||||||
|
/// de l'environnement détecté + override par variable d'environnement. Log
|
||||||
|
/// la décision pour faciliter le debug si l'utilisateur reporte un écran blanc.
|
||||||
|
/// </summary>
|
||||||
|
private static void ApplyRenderModeFallback()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Override manuel : PSLAUNCHER_SOFTWARE_RENDER=1 force le software.
|
||||||
|
// Le user peut le set dans les variables d'environnement Windows OU dans
|
||||||
|
// le raccourci .lnk (champ Cible : "cmd /c set PSLAUNCHER_SOFTWARE_RENDER=1 && start PS_Launcher.exe")
|
||||||
|
var envOverride = Environment.GetEnvironmentVariable("PSLAUNCHER_SOFTWARE_RENDER");
|
||||||
|
var manualSoftware = !string.IsNullOrEmpty(envOverride)
|
||||||
|
&& (envOverride == "1" || envOverride.Equals("true", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
// RenderCapability.Tier renvoie un int packed : (tier << 16). Tier 0 = pas
|
||||||
|
// d'accélération matérielle possible. Tier 1 = partielle. Tier 2 = complète.
|
||||||
|
var tier = System.Windows.Media.RenderCapability.Tier >> 16;
|
||||||
|
|
||||||
|
// GetSystemMetrics(SM_REMOTESESSION) = 1 si la session est une session RDP.
|
||||||
|
// SM_REMOTESESSION constant = 0x1000 (4096).
|
||||||
|
var isRdp = GetSystemMetrics(0x1000) != 0;
|
||||||
|
|
||||||
|
var shouldUseSoftware = manualSoftware || tier == 0 || isRdp;
|
||||||
|
|
||||||
|
Log.Information("Render env : Tier={Tier} RDP={Rdp} EnvOverride={EnvOverride} → SoftwareOnly={Sw}",
|
||||||
|
tier, isRdp, manualSoftware, shouldUseSoftware);
|
||||||
|
|
||||||
|
if (shouldUseSoftware)
|
||||||
|
{
|
||||||
|
System.Windows.Media.RenderOptions.ProcessRenderMode =
|
||||||
|
System.Windows.Interop.RenderMode.SoftwareOnly;
|
||||||
|
Log.Warning("WPF render mode forced to SoftwareOnly (cause: " +
|
||||||
|
(manualSoftware ? "env override" : tier == 0 ? "Tier=0 no GPU accel" : "RDP session") + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Pas critique : si le fallback échoue, on continue avec le default
|
||||||
|
// hardware accel et on espère que ça marche. Le user verra un écran
|
||||||
|
// blanc si non, mais au moins l'app aura pas crashé.
|
||||||
|
Log.Warning(ex, "Failed to evaluate render-mode fallback, keeping default");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern int GetSystemMetrics(int nIndex);
|
||||||
|
|
||||||
protected override void OnExit(ExitEventArgs e)
|
protected override void OnExit(ExitEventArgs e)
|
||||||
{
|
{
|
||||||
Log.Information("PSLauncher shutting down");
|
Log.Information("PSLauncher shutting down");
|
||||||
|
// Stop propre des hosted services (laisse 5s pour qu'ils ferment leurs
|
||||||
|
// sockets HttpListener / UdpClient sans laisser le port en TIME_WAIT trop
|
||||||
|
// longtemps). Best-effort : si timeout, on continue le shutdown quand même.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||||
|
_host?.StopAsync(stopCts.Token).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Log.Warning(ex, "Host StopAsync failed (continuing shutdown)"); }
|
||||||
Log.CloseAndFlush();
|
Log.CloseAndFlush();
|
||||||
_host?.Dispose();
|
_host?.Dispose();
|
||||||
base.OnExit(e);
|
base.OnExit(e);
|
||||||
|
|||||||
@@ -9,12 +9,18 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<ApplicationIcon></ApplicationIcon>
|
<!-- Le générateur WinRT (CsWinRT v2.x, déclenché par WebView2/UWP refs sur SDK récent)
|
||||||
<AssemblyName>PSLauncher</AssemblyName>
|
émet du code unsafe dans WinRTGenericInstantiation.g.cs. Sans ce flag, build = CS0227. -->
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
<ApplicationIcon>Resources\favicon.ico</ApplicationIcon>
|
||||||
|
<AssemblyName>PS_Launcher</AssemblyName>
|
||||||
|
<Company>ASTERION VR</Company>
|
||||||
|
<Product>PROSERVE Launcher</Product>
|
||||||
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||||
<Version>0.5.0</Version>
|
<Version>1.0.13</Version>
|
||||||
<AssemblyVersion>0.5.0.0</AssemblyVersion>
|
<AssemblyVersion>1.0.13.0</AssemblyVersion>
|
||||||
<FileVersion>0.5.0.0</FileVersion>
|
<FileVersion>1.0.13.0</FileVersion>
|
||||||
|
|
||||||
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
@@ -37,6 +43,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.3800.47" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -47,11 +58,14 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Resource Include="Resources\Background.png" />
|
<Resource Include="Resources\Background.png" />
|
||||||
<Resource Include="Resources\pirulen.otf" />
|
<Resource Include="Resources\pirulen.otf" />
|
||||||
|
<Resource Include="Resources\favicon.ico" />
|
||||||
|
<Resource Include="Resources\logo-asterion-white.png" />
|
||||||
</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) pour avoir un binaire prêt à tester
|
(C:\ASTERION\GIT\PS_Launcher\PS_Launcher.exe). ContinueOnError pour ne pas casser le
|
||||||
sans aller chercher dans bin\Release\... -->
|
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\... -->
|
||||||
<Target Name="CopyPublishedToRepoRoot" AfterTargets="Publish"
|
<Target Name="CopyPublishedToRepoRoot" AfterTargets="Publish"
|
||||||
Condition=" '$(Configuration)' == 'Release' ">
|
Condition=" '$(Configuration)' == 'Release' ">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -60,7 +74,8 @@
|
|||||||
<Copy SourceFiles="$(PublishDir)$(AssemblyName).exe"
|
<Copy SourceFiles="$(PublishDir)$(AssemblyName).exe"
|
||||||
DestinationFolder="$(RepoRoot)"
|
DestinationFolder="$(RepoRoot)"
|
||||||
SkipUnchangedFiles="false"
|
SkipUnchangedFiles="false"
|
||||||
OverwriteReadOnlyFiles="true" />
|
OverwriteReadOnlyFiles="true"
|
||||||
|
ContinueOnError="WarnAndContinue" />
|
||||||
<Message Importance="high" Text="Copied $(AssemblyName).exe to $(RepoRoot)" />
|
<Message Importance="high" Text="Copied $(AssemblyName).exe to $(RepoRoot)" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -10,12 +11,18 @@
|
|||||||
<!-- Inverse bool : utilisé pour griser un bouton pendant qu'une opération est en cours -->
|
<!-- Inverse bool : utilisé pour griser un bouton pendant qu'une opération est en cours -->
|
||||||
<res:InverseBoolConverter x:Key="InverseBoolToBool" />
|
<res:InverseBoolConverter x:Key="InverseBoolToBool" />
|
||||||
|
|
||||||
<!-- Fond fenêtre : noir pur pour ne pas délaver le bitmap overlay -->
|
<!-- Fond fenêtre : presque noir avec un soupçon de bleu. v0.18 a tenté
|
||||||
<SolidColorBrush x:Key="Brush.Bg.Window" Color="#000000" />
|
un navy plus prononcé (#0A1220) mais c'était trop bleu — on revient
|
||||||
<!-- Cards & sidebar : très légère teinte bleutée pour le contraste -->
|
à un noir-bleuté très subtil pour rester sombre tout en gardant
|
||||||
<SolidColorBrush x:Key="Brush.Bg.Sidebar" Color="#0E1218" />
|
l'identité ASTERION VR. -->
|
||||||
<SolidColorBrush x:Key="Brush.Bg.Card" Color="#161B23" />
|
<SolidColorBrush x:Key="Brush.Bg.Window" Color="#050A14" />
|
||||||
<SolidColorBrush x:Key="Brush.Bg.Footer" Color="#050709" />
|
<SolidColorBrush x:Key="Brush.Bg.Sidebar" Color="#0B1018" />
|
||||||
|
<SolidColorBrush x:Key="Brush.Bg.Card" Color="#131826" />
|
||||||
|
<SolidColorBrush x:Key="Brush.Bg.Footer" Color="#04070D" />
|
||||||
|
|
||||||
|
<!-- Voile bleu très léger (4%) sur le bitmap de background. Juste de quoi
|
||||||
|
donner une nuance, pas un voile coloré franc. -->
|
||||||
|
<SolidColorBrush x:Key="Brush.Bg.BlueTint" Color="#3050A0" Opacity="0.04" />
|
||||||
<SolidColorBrush x:Key="Brush.Border" Color="#2A2F3A" />
|
<SolidColorBrush x:Key="Brush.Border" Color="#2A2F3A" />
|
||||||
<SolidColorBrush x:Key="Brush.Text.Primary" Color="#F2F2F2" />
|
<SolidColorBrush x:Key="Brush.Text.Primary" Color="#F2F2F2" />
|
||||||
<SolidColorBrush x:Key="Brush.Text.Secondary" Color="#A0A0A8" />
|
<SolidColorBrush x:Key="Brush.Text.Secondary" Color="#A0A0A8" />
|
||||||
@@ -72,6 +79,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" />
|
||||||
@@ -135,6 +183,39 @@
|
|||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
DangerButton : action destructive (rouge). Utilisé pour le bouton « Annuler »
|
||||||
|
à côté de « ↻ Reprendre » sur les rows ayant un téléchargement partiel — pour
|
||||||
|
que l'utilisateur puisse abandonner le partial et repartir de zéro.
|
||||||
|
-->
|
||||||
|
<Style x:Key="DangerButton" TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
|
||||||
|
<Setter Property="Background" Value="#DC2626" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Padding" Value="14,8" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#EF4444" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#444" />
|
||||||
|
<Setter Property="Foreground" Value="#888" />
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Boutons de chrome window (min / max / close), 46x40 façon Windows -->
|
<!-- Boutons de chrome window (min / max / close), 46x40 façon Windows -->
|
||||||
<Style x:Key="WindowControlButton" TargetType="Button">
|
<Style x:Key="WindowControlButton" TargetType="Button">
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
@@ -154,7 +235,8 @@
|
|||||||
</Border>
|
</Border>
|
||||||
<ControlTemplate.Triggers>
|
<ControlTemplate.Triggers>
|
||||||
<Trigger Property="IsMouseOver" Value="True">
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
<Setter TargetName="Bd" Property="Background" Value="#FFFFFF22" />
|
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.Accent}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
</Trigger>
|
</Trigger>
|
||||||
</ControlTemplate.Triggers>
|
</ControlTemplate.Triggers>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
@@ -205,6 +287,177 @@
|
|||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- ToolTip sombre.
|
||||||
|
Le ToolTip natif WPF est blanc + texte noir + corner radius zéro,
|
||||||
|
look Win 7-ish qui jure complètement avec le thème dark. On le
|
||||||
|
re-template : fond Bg.Card, bordure Border, texte Text.Primary,
|
||||||
|
coins arrondis, padding plus généreux, et MaxWidth pour que les
|
||||||
|
tooltips longs (ex. health banner avec 4-5 lignes de détail)
|
||||||
|
wrappent au lieu de filer en hors-écran. -->
|
||||||
|
<Style TargetType="ToolTip">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Brush.Bg.Card}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Border}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Padding" Value="12,8" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="MaxWidth" Value="360" />
|
||||||
|
<Setter Property="HasDropShadow" Value="True" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ToolTip">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
CornerRadius="6">
|
||||||
|
<ContentPresenter>
|
||||||
|
<ContentPresenter.Resources>
|
||||||
|
<!-- ContentPresenter wrapping ToolTip Content :
|
||||||
|
si Content est une string, on veut un TextBlock
|
||||||
|
qui wrappe. Sinon c'est un visual user-fourni. -->
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
</Style>
|
||||||
|
</ContentPresenter.Resources>
|
||||||
|
</ContentPresenter>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- ComboBox sombre.
|
||||||
|
Re-template minimal pour avoir le contenu, l'arrow et le popup tous en
|
||||||
|
palette dark. WPF native ComboBox style serait grisé sur blanc. -->
|
||||||
|
<Style x:Key="ComboBoxToggleButton" TargetType="ToggleButton">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border Background="Transparent">
|
||||||
|
<Path x:Name="Arrow"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Data="M 0,0 L 8,0 L 4,5 Z"
|
||||||
|
Fill="{StaticResource Brush.Text.Secondary}" />
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Arrow" Property="Fill" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="ComboBox">
|
||||||
|
<Setter Property="Background" Value="#1A1A20" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Brush.Border}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<Setter Property="Padding" Value="8,4" />
|
||||||
|
<Setter Property="MinHeight" Value="32" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ComboBox">
|
||||||
|
<Grid>
|
||||||
|
<!-- ToggleButton couvre TOUT le combo : clic au centre ouvre le dropdown,
|
||||||
|
pas seulement clic sur la flèche. Le ContentPresenter par-dessus
|
||||||
|
est en IsHitTestVisible=False pour ne pas voler le clic. -->
|
||||||
|
<ToggleButton x:Name="ToggleBtn"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
Cursor="Hand"
|
||||||
|
Focusable="False"
|
||||||
|
IsChecked="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
|
||||||
|
<ToggleButton.Template>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="4">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="28" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Path Grid.Column="1"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Data="M 0,0 L 8,0 L 4,5 Z"
|
||||||
|
Fill="{StaticResource Brush.Text.Secondary}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Brush.Accent}" />
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ToggleButton.Template>
|
||||||
|
</ToggleButton>
|
||||||
|
<ContentPresenter IsHitTestVisible="False"
|
||||||
|
Content="{TemplateBinding SelectionBoxItem}"
|
||||||
|
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||||
|
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}"
|
||||||
|
Margin="{TemplateBinding Padding}"
|
||||||
|
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||||
|
TextElement.Foreground="{TemplateBinding Foreground}" />
|
||||||
|
<Popup x:Name="PART_Popup"
|
||||||
|
Placement="Bottom"
|
||||||
|
IsOpen="{TemplateBinding IsDropDownOpen}"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
Focusable="False"
|
||||||
|
PopupAnimation="Slide">
|
||||||
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="4"
|
||||||
|
MinWidth="{TemplateBinding ActualWidth}"
|
||||||
|
MaxHeight="300">
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsPresenter />
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="ComboBoxItem">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<Setter Property="Padding" Value="10,6" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ComboBoxItem">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter />
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsHighlighted" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.Accent}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#2C2C32" />
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Style ContextMenu sombre, cohérent avec le thème.
|
<!-- Style ContextMenu sombre, cohérent avec le thème.
|
||||||
On retemplatise complètement pour virer la "icon column" claire que WPF
|
On retemplatise complètement pour virer la "icon column" claire que WPF
|
||||||
dessine par défaut sur le bord gauche du popup. -->
|
dessine par défaut sur le bord gauche du popup. -->
|
||||||
|
|||||||
BIN
src/PSLauncher.App/Resources/favicon.ico
Normal file
BIN
src/PSLauncher.App/Resources/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src/PSLauncher.App/Resources/favicon.jpg
Normal file
BIN
src/PSLauncher.App/Resources/favicon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
src/PSLauncher.App/Resources/logo-asterion-white.png
Normal file
BIN
src/PSLauncher.App/Resources/logo-asterion-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
92
src/PSLauncher.App/ViewModels/HealthIndicatorViewModel.cs
Normal file
92
src/PSLauncher.App/ViewModels/HealthIndicatorViewModel.cs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Windows.Media;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using PSLauncher.Core.Health;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.App.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Représente un indicateur visuel (pill colorée) dans le bandeau de santé
|
||||||
|
/// système. Mis à jour périodiquement par <see cref="MainViewModel"/> à
|
||||||
|
/// partir du résultat de <see cref="ISystemHealthService.RunCheckAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class HealthIndicatorViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
public HealthCheckEntry Entry { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(StatusBrush))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(BorderBrush))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IconForeground))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Tooltip))]
|
||||||
|
private HealthSeverity _severity = HealthSeverity.Unknown;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Tooltip))]
|
||||||
|
private string _detail = "Pas encore vérifié";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(Tooltip))]
|
||||||
|
private DateTime _lastCheckedUtc = DateTime.MinValue;
|
||||||
|
|
||||||
|
public string Name => Entry.Name;
|
||||||
|
public string Icon => Entry.Icon;
|
||||||
|
|
||||||
|
public Brush StatusBrush => Severity switch
|
||||||
|
{
|
||||||
|
// Pill background : un peu transparent pour que le bandeau reste discret
|
||||||
|
HealthSeverity.Ok => new SolidColorBrush(Color.FromArgb(0x40, 0x16, 0xA3, 0x4A)), // vert
|
||||||
|
HealthSeverity.Warning => new SolidColorBrush(Color.FromArgb(0x50, 0xF5, 0x9E, 0x0B)), // orange
|
||||||
|
HealthSeverity.Error => new SolidColorBrush(Color.FromArgb(0x50, 0xEF, 0x44, 0x44)), // rouge
|
||||||
|
_ => new SolidColorBrush(Color.FromArgb(0x30, 0x80, 0x80, 0x90)), // gris
|
||||||
|
};
|
||||||
|
|
||||||
|
public Brush BorderBrush => Severity switch
|
||||||
|
{
|
||||||
|
HealthSeverity.Ok => new SolidColorBrush(Color.FromRgb(0x16, 0xA3, 0x4A)),
|
||||||
|
HealthSeverity.Warning => new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B)),
|
||||||
|
HealthSeverity.Error => new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44)),
|
||||||
|
_ => new SolidColorBrush(Color.FromRgb(0x60, 0x60, 0x70)),
|
||||||
|
};
|
||||||
|
|
||||||
|
public Brush IconForeground => Severity switch
|
||||||
|
{
|
||||||
|
HealthSeverity.Ok => new SolidColorBrush(Color.FromRgb(0x4A, 0xDE, 0x80)),
|
||||||
|
HealthSeverity.Warning => new SolidColorBrush(Color.FromRgb(0xFB, 0xBF, 0x24)),
|
||||||
|
HealthSeverity.Error => new SolidColorBrush(Color.FromRgb(0xF8, 0x71, 0x71)),
|
||||||
|
_ => new SolidColorBrush(Color.FromRgb(0xA0, 0xA0, 0xA8)),
|
||||||
|
};
|
||||||
|
|
||||||
|
public string Tooltip
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var status = Severity switch
|
||||||
|
{
|
||||||
|
HealthSeverity.Ok => "✓ OK",
|
||||||
|
HealthSeverity.Warning => "⚠ Limitation détectée",
|
||||||
|
HealthSeverity.Error => "⛔ Problème",
|
||||||
|
_ => "❓ Non vérifié",
|
||||||
|
};
|
||||||
|
var ts = LastCheckedUtc == DateTime.MinValue
|
||||||
|
? ""
|
||||||
|
: $"\n\nDernière vérif : {LastCheckedUtc.ToLocalTime():HH:mm:ss}";
|
||||||
|
var kindLabel = Entry.Kind?.Equals("ping", StringComparison.OrdinalIgnoreCase) == true
|
||||||
|
? $"Ping {Entry.Target}"
|
||||||
|
: $"Processus {Entry.Target}";
|
||||||
|
return $"{Entry.Icon} {Entry.Name} — {status}\n{kindLabel}\n\n{Detail}{ts}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public HealthIndicatorViewModel(HealthCheckEntry entry)
|
||||||
|
{
|
||||||
|
Entry = entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Apply(HealthResult result)
|
||||||
|
{
|
||||||
|
Severity = result.Severity;
|
||||||
|
Detail = result.Detail;
|
||||||
|
LastCheckedUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.App.ViewModels;
|
namespace PSLauncher.App.ViewModels;
|
||||||
@@ -11,17 +12,7 @@ public sealed class InstalledVersionViewModel
|
|||||||
public string Display => $"v{Model.Version}";
|
public string Display => $"v{Model.Version}";
|
||||||
|
|
||||||
public string Subtitle =>
|
public string Subtitle =>
|
||||||
$"{FormatSize(Model.SizeBytes)} • Installée le {Model.InstalledAt.ToLocalTime():dd/MM/yyyy}";
|
$"{Strings.FormatSize(Model.SizeBytes)} • {Strings.FormatDate(Model.InstalledAt)}";
|
||||||
|
|
||||||
private static string FormatSize(long bytes)
|
|
||||||
{
|
|
||||||
if (bytes <= 0) return "—";
|
|
||||||
string[] units = { "o", "Ko", "Mo", "Go", "To" };
|
|
||||||
double v = bytes;
|
|
||||||
int u = 0;
|
|
||||||
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
|
|
||||||
return $"{v:0.#} {units[u]}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => Display;
|
public override string ToString() => Display;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.App.ViewModels;
|
namespace PSLauncher.App.ViewModels;
|
||||||
@@ -9,6 +10,7 @@ public enum VersionRowState
|
|||||||
InstalledIdle, // Installée localement, pas d'action en cours
|
InstalledIdle, // Installée localement, pas d'action en cours
|
||||||
AvailableIdle, // Disponible côté serveur, pas installée
|
AvailableIdle, // Disponible côté serveur, pas installée
|
||||||
Downloading,
|
Downloading,
|
||||||
|
Verifying, // SHA-256 du ZIP téléchargé en cours
|
||||||
Installing,
|
Installing,
|
||||||
Uninstalling,
|
Uninstalling,
|
||||||
}
|
}
|
||||||
@@ -24,11 +26,79 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
public VersionManifest? Remote { get; }
|
public VersionManifest? Remote { get; }
|
||||||
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
|
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clé stable qui identifie une row au-delà du simple <see cref="Version"/>,
|
||||||
|
/// pour distinguer deux entrées manifest partageant le même numéro sur des
|
||||||
|
/// channels différents (ex : proserve-firefighter-1.5.4.32 vs proserve-full-
|
||||||
|
/// 1.5.4.32). Priorité :
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Basename du dossier d'install (côté installed, ou côté remote
|
||||||
|
/// résolu via <c>InstallFolderTemplate</c>) — unique par convention car
|
||||||
|
/// deux entries au même version DOIVENT avoir des dossiers distincts
|
||||||
|
/// (validé backoffice + install guard client).</item>
|
||||||
|
/// <item>Fallback : le <see cref="Version"/> seul (mono-channel, comportement
|
||||||
|
/// identique à avant).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Utilisé pour identifier une row lors des lookups par état actif (DL en
|
||||||
|
/// cours, install courant), au lieu de matcher par <c>Version</c> qui devient
|
||||||
|
/// ambigu sur les setups multi-channels.
|
||||||
|
/// </summary>
|
||||||
|
public string RowKey { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Channel principal à afficher comme badge sur la row (null / vide → pas
|
||||||
|
/// de badge). Sélection : le premier channel non-« default » de
|
||||||
|
/// <see cref="VersionManifest.Channels"/>, en <c>OrdinalIgnoreCase</c>. Pour
|
||||||
|
/// une row installée sans remote (orpheline), c'est null. Pour une entrée
|
||||||
|
/// remote sans Channels ou uniquement « default », c'est null.
|
||||||
|
/// </summary>
|
||||||
|
public string? ChannelBadge => Remote?.Channels?
|
||||||
|
.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c)
|
||||||
|
&& !string.Equals(c, "default", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True si au moins une AUTRE row visible sur ce launcher partage le même
|
||||||
|
/// numéro de version. Positionné par <c>MainViewModel.RebuildList</c> après
|
||||||
|
/// construction des rows (group by Version, count >= 2 → conflict).
|
||||||
|
/// Le badge channel n'est utile QUE dans ce cas — sinon la row est
|
||||||
|
/// univoque et le badge ajoute du bruit visuel.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasChannelBadge))]
|
||||||
|
private bool _hasVersionConflict;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Le badge n'est visible QUE quand la row a un channel spécifique ET qu'une
|
||||||
|
/// autre row visible partage le même numéro de version. Ça garde l'UI propre
|
||||||
|
/// sur les setups mono-channel (99 % des cas) et affiche le distingueur
|
||||||
|
/// seulement quand il est réellement nécessaire.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasChannelBadge => !string.IsNullOrEmpty(ChannelBadge) && HasVersionConflict;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True si la version est taggée BÊTA dans le manifest (champ
|
||||||
|
/// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge
|
||||||
|
/// orange "BÊTA" + tooltip avec <see cref="BetaNotes"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsBeta => Remote?.IsBeta ?? false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Note des testeurs (texte libre). Affichée en tooltip quand on hover
|
||||||
|
/// le badge BÊTA. Null si non renseigné côté serveur.
|
||||||
|
/// </summary>
|
||||||
|
public string? BetaNotes => Remote?.BetaNotes;
|
||||||
|
|
||||||
|
/// <summary>Tooltip composé pour le hover sur le badge BÊTA.</summary>
|
||||||
|
public string BetaTooltip => string.IsNullOrEmpty(BetaNotes)
|
||||||
|
? Strings.BetaBadgeTooltipDefault
|
||||||
|
: Strings.BetaBadgeTooltip(BetaNotes!);
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(StateLabel))]
|
[NotifyPropertyChangedFor(nameof(StateLabel))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsBusy))]
|
[NotifyPropertyChangedFor(nameof(IsBusy))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsInstalled))]
|
[NotifyPropertyChangedFor(nameof(IsInstalled))]
|
||||||
[NotifyPropertyChangedFor(nameof(IsRemoteOnly))]
|
[NotifyPropertyChangedFor(nameof(IsRemoteOnly))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
|
[NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
|
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
|
||||||
[NotifyCanExecuteChangedFor(nameof(UninstallCommand))]
|
[NotifyCanExecuteChangedFor(nameof(UninstallCommand))]
|
||||||
@@ -40,6 +110,8 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(InstallButtonLabel))]
|
[NotifyPropertyChangedFor(nameof(InstallButtonLabel))]
|
||||||
[NotifyPropertyChangedFor(nameof(HasResumableDownload))]
|
[NotifyPropertyChangedFor(nameof(HasResumableDownload))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(RestartFromZeroCommand))]
|
||||||
private long _resumableBytes;
|
private long _resumableBytes;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -48,37 +120,87 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
|
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
|
||||||
private bool _licenseAllowsDownload = true;
|
private bool _licenseAllowsDownload = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True quand cette version est la version désignée pour l'auto-launch
|
||||||
|
/// (bouton AUTO orange/coloré). Une seule row peut être sélectionnée à la
|
||||||
|
/// fois ; le MainViewModel maintient l'unicité quand on toggle.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isAutoModeSelected;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True si le bouton AUTO doit être affiché sur cette row (master toggle ON
|
||||||
|
/// + version installée). Piloté par le MainViewModel via property update à
|
||||||
|
/// chaque RebuildList.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _showAutoButton;
|
||||||
|
|
||||||
|
public Action<VersionRowViewModel>? ToggleAutoModeHandler { get; set; }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleAutoMode()
|
||||||
|
{
|
||||||
|
// Trace fichier pour diagnostiquer "le bouton AUTO ne fait rien".
|
||||||
|
// Si on voit cette ligne mais pas la suivante côté MainViewModel,
|
||||||
|
// c'est que le handler n'est pas attaché (bug WireRowHandlers).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = System.IO.Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"PSLauncher", "logs");
|
||||||
|
System.IO.Directory.CreateDirectory(dir);
|
||||||
|
System.IO.File.AppendAllText(
|
||||||
|
System.IO.Path.Combine(dir, "auto-mode.log"),
|
||||||
|
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z VersionRowViewModel.ToggleAutoMode invoked for v{Version} (handler attached={ToggleAutoModeHandler is not null}){Environment.NewLine}");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
ToggleAutoModeHandler?.Invoke(this);
|
||||||
|
}
|
||||||
|
|
||||||
public bool HasResumableDownload => ResumableBytes > 0;
|
public bool HasResumableDownload => ResumableBytes > 0;
|
||||||
public bool LicenseAllowsInstall => LicenseAllowsDownload;
|
public bool LicenseAllowsInstall => LicenseAllowsDownload;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visibilité du bouton rouge « Annuler » à côté de « ↻ Reprendre ».
|
||||||
|
/// Doit être vrai uniquement quand un partial existe ET qu'aucun DL n'est en
|
||||||
|
/// cours sur cette row (sinon le bouton « Annuler » se mélange visuellement
|
||||||
|
/// avec la barre de progression du DL actif, qui a son propre cancel).
|
||||||
|
/// </summary>
|
||||||
|
public bool ShowRestartFromZero => HasResumableDownload && State == VersionRowState.AvailableIdle;
|
||||||
|
|
||||||
public string InstallButtonLabel
|
public string InstallButtonLabel
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (!LicenseAllowsDownload) return "🔒 License insuffisante";
|
if (!LicenseAllowsDownload) return Strings.ActionLicenseInsufficient;
|
||||||
if (!HasResumableDownload) return "⬇ Installer";
|
if (!HasResumableDownload) return Strings.ActionInstall;
|
||||||
if (Remote is null || Remote.Download.SizeBytes <= 0) return "↻ Reprendre";
|
if (Remote is null || Remote.Download.SizeBytes <= 0) return Strings.ResumeButtonNoPercent;
|
||||||
var pct = (double)ResumableBytes / Remote.Download.SizeBytes * 100.0;
|
var pct = (int)Math.Round((double)ResumableBytes / Remote.Download.SizeBytes * 100.0);
|
||||||
return $"↻ Reprendre ({pct:0}%)";
|
return Strings.ResumeButton(pct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsInstalled => State is VersionRowState.InstalledIdle or VersionRowState.Uninstalling;
|
public bool IsInstalled => State is VersionRowState.InstalledIdle or VersionRowState.Uninstalling;
|
||||||
public bool IsRemoteOnly => State is VersionRowState.AvailableIdle;
|
public bool IsRemoteOnly => State is VersionRowState.AvailableIdle;
|
||||||
public bool IsBusy => State is VersionRowState.Downloading or VersionRowState.Installing or VersionRowState.Uninstalling;
|
public bool IsBusy => State is VersionRowState.Downloading
|
||||||
|
or VersionRowState.Verifying
|
||||||
|
or VersionRowState.Installing
|
||||||
|
or VersionRowState.Uninstalling;
|
||||||
|
|
||||||
public string StateLabel => State switch
|
public string StateLabel => State switch
|
||||||
{
|
{
|
||||||
VersionRowState.InstalledIdle => "● Installée",
|
VersionRowState.InstalledIdle => Strings.StatusInstalled,
|
||||||
VersionRowState.AvailableIdle => "○ Disponible",
|
VersionRowState.AvailableIdle => Strings.StatusAvailable,
|
||||||
VersionRowState.Downloading => "⬇ Téléchargement…",
|
VersionRowState.Downloading => Strings.StatusDownloading,
|
||||||
VersionRowState.Installing => "📦 Installation…",
|
VersionRowState.Verifying => Strings.StatusVerifying,
|
||||||
VersionRowState.Uninstalling => "🗑 Suppression…",
|
VersionRowState.Installing => Strings.StatusInstalling,
|
||||||
|
VersionRowState.Uninstalling => Strings.StatusUninstalling,
|
||||||
_ => string.Empty
|
_ => string.Empty
|
||||||
};
|
};
|
||||||
|
|
||||||
public string PrimaryDate => ReleasedAt > DateTime.MinValue
|
public string PrimaryDate => ReleasedAt > DateTime.MinValue
|
||||||
? ReleasedAt.ToLocalTime().ToString("dd/MM/yyyy")
|
? Strings.FormatDate(ReleasedAt)
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
public string SizeDisplay => FormatSize(SizeBytes);
|
public string SizeDisplay => FormatSize(SizeBytes);
|
||||||
@@ -124,6 +246,19 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
Installed = installed;
|
Installed = installed;
|
||||||
Remote = remote;
|
Remote = remote;
|
||||||
_state = initialState;
|
_state = initialState;
|
||||||
|
|
||||||
|
// RowKey — priorité : (1) Id de l'entrée manifest (unique par entry, survit
|
||||||
|
// à un rename de folder), (2) EntryId lu depuis le .proserve-meta.json de
|
||||||
|
// l'install, (3) folder name basename, (4) version en dernier recours.
|
||||||
|
// Doit matcher la logique de MainViewModel.RebuildList pour que les lookups
|
||||||
|
// par RowKey trouvent la row correcte.
|
||||||
|
var folderName = !string.IsNullOrEmpty(folderPath)
|
||||||
|
? System.IO.Path.GetFileName(folderPath)
|
||||||
|
: remote?.GetInstallFolderName();
|
||||||
|
RowKey = !string.IsNullOrEmpty(remote?.Id) ? remote!.Id!
|
||||||
|
: !string.IsNullOrEmpty(installed?.EntryId) ? installed!.EntryId!
|
||||||
|
: !string.IsNullOrWhiteSpace(folderName) ? folderName!
|
||||||
|
: version;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Les commandes sont câblées par le MainViewModel après instanciation
|
// Les commandes sont câblées par le MainViewModel après instanciation
|
||||||
@@ -134,6 +269,8 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
public Action<VersionRowViewModel>? UninstallHandler { get; set; }
|
public Action<VersionRowViewModel>? UninstallHandler { get; set; }
|
||||||
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
|
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
|
||||||
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
|
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
|
||||||
|
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
|
||||||
|
public Action<VersionRowViewModel>? ForceFreshDownloadHandler { get; set; }
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanLaunch))]
|
[RelayCommand(CanExecute = nameof(CanLaunch))]
|
||||||
private void Launch() => LaunchHandler?.Invoke(this);
|
private void Launch() => LaunchHandler?.Invoke(this);
|
||||||
@@ -153,13 +290,19 @@ public sealed partial class VersionRowViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenFolder() => OpenFolderHandler?.Invoke(this);
|
private void OpenFolder() => OpenFolderHandler?.Invoke(this);
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanRestartFromZero))]
|
||||||
|
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
|
||||||
|
private bool CanRestartFromZero() => HasResumableDownload;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Visible quand la row n'est pas en cours de DL (sinon le user devrait
|
||||||
|
/// d'abord cancel via le bouton dédié). Pas conditionnée par "partial existe"
|
||||||
|
/// car cette action a pour but spécifique de purger TOUT (cache + partial +
|
||||||
|
/// sidecar SHA), même quand il n'y a pas de partial en cours.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void ForceFreshDownload() => ForceFreshDownloadHandler?.Invoke(this);
|
||||||
|
|
||||||
private static string FormatSize(long bytes)
|
private static string FormatSize(long bytes)
|
||||||
{
|
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
|
||||||
if (bytes <= 0) return "—";
|
|
||||||
string[] units = { "o", "Ko", "Mo", "Go", "To" };
|
|
||||||
double v = bytes;
|
|
||||||
int u = 0;
|
|
||||||
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
|
|
||||||
return $"{v:0.#} {units[u]}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml
Normal file
72
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<Window x:Class="PSLauncher.App.Views.AutoRelaunchDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||||
|
Title="{x:Static loc:Strings.AutoRelaunchTitle}"
|
||||||
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
|
Width="560" Height="420"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Topmost="True"
|
||||||
|
Background="{StaticResource Brush.Bg.Window}">
|
||||||
|
<Grid Margin="36,32">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Titre dynamique : "Vérification de la santé système" pendant la
|
||||||
|
phase d'attente, "Lancement automatique" pendant le countdown. -->
|
||||||
|
<TextBlock Grid.Row="0"
|
||||||
|
x:Name="TitleText"
|
||||||
|
Text="{x:Static loc:Strings.AutoRelaunchTitle}"
|
||||||
|
FontSize="22" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
x:Name="MessageText"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
TextAlignment="Center"
|
||||||
|
Margin="0,16,0,0" />
|
||||||
|
|
||||||
|
<!-- Affiché pendant la phase santé : compteur d'indicateurs en attente
|
||||||
|
+ nom des checks non-OK. Masqué pendant le countdown. -->
|
||||||
|
<TextBlock Grid.Row="2"
|
||||||
|
x:Name="HealthPendingText"
|
||||||
|
FontSize="13"
|
||||||
|
Foreground="#FBBF24"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
TextAlignment="Center"
|
||||||
|
Margin="0,12,0,0"
|
||||||
|
Visibility="Collapsed" />
|
||||||
|
|
||||||
|
<!-- Affiché pendant le countdown. Gros chiffre orange. -->
|
||||||
|
<TextBlock Grid.Row="2"
|
||||||
|
x:Name="CountdownText"
|
||||||
|
FontSize="72"
|
||||||
|
FontWeight="Bold"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Foreground="{StaticResource Brush.Accent}"
|
||||||
|
Margin="0,20,0,0"
|
||||||
|
Visibility="Collapsed" />
|
||||||
|
|
||||||
|
<!-- Row 3 = espacement * pour aérer entre le contenu et le bouton -->
|
||||||
|
|
||||||
|
<Button Grid.Row="4"
|
||||||
|
Style="{StaticResource DangerButton}"
|
||||||
|
Content="{x:Static loc:Strings.AutoRelaunchCancel}"
|
||||||
|
FontSize="14"
|
||||||
|
Padding="40,12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Margin="0,16,0,0"
|
||||||
|
IsCancel="True"
|
||||||
|
IsDefault="True"
|
||||||
|
Click="OnCancel" />
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
162
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml.cs
Normal file
162
src/PSLauncher.App/Views/AutoRelaunchDialog.xaml.cs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
|
|
||||||
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal de lancement automatique de PROSERVE. Utilisé pour les 3 entry points
|
||||||
|
/// du mode auto :
|
||||||
|
/// 1. Click sur le bouton AUTO d'une row → désigne + lance
|
||||||
|
/// 2. Démarrage du launcher avec mode auto configuré → lance
|
||||||
|
/// 3. PROSERVE quitte alors qu'il était la version auto → relance
|
||||||
|
///
|
||||||
|
/// Deux phases possibles selon <see cref="AutoModeConfig.WaitForHealthChecks"/> :
|
||||||
|
/// • <b>Phase santé</b> (optionnelle) : si la config dit d'attendre que tous
|
||||||
|
/// les indicateurs de santé système soient OK, on poll le callback
|
||||||
|
/// <see cref="_healthSnapshot"/> tant qu'il retourne une liste non vide.
|
||||||
|
/// Le bouton Annuler permet de sortir et désactiver le mode auto.
|
||||||
|
/// • <b>Phase countdown</b> : décompte visuel de N secondes avant lancement
|
||||||
|
/// (configurable via GracePeriodSeconds, clampé 1-60).
|
||||||
|
///
|
||||||
|
/// DialogResult :
|
||||||
|
/// <c>true</c> = phase countdown terminée à zéro, MainViewModel doit lancer
|
||||||
|
/// <c>false</c> = l'opérateur a cliqué Annuler (à n'importe quelle phase),
|
||||||
|
/// MainViewModel doit désactiver SelectedVersion
|
||||||
|
/// </summary>
|
||||||
|
public partial class AutoRelaunchDialog : Window
|
||||||
|
{
|
||||||
|
private readonly DispatcherTimer _timer;
|
||||||
|
private readonly int _graceSeconds;
|
||||||
|
private readonly string _versionName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback fourni par MainViewModel pour interroger l'état de santé.
|
||||||
|
/// Retourne la liste des NOMS d'indicateurs qui ne sont PAS encore OK
|
||||||
|
/// (Warning, Error, ou Unknown). Liste vide ⇒ tout est OK ⇒ on peut
|
||||||
|
/// passer à la phase countdown. Null si la phase santé n'est pas
|
||||||
|
/// demandée pour ce lancement (option WaitForHealthChecks=false ou
|
||||||
|
/// aucun health check configuré).
|
||||||
|
/// </summary>
|
||||||
|
private readonly Func<IReadOnlyList<string>>? _healthSnapshot;
|
||||||
|
|
||||||
|
private enum Phase { HealthWait, Countdown }
|
||||||
|
private Phase _phase;
|
||||||
|
private int _remainingSeconds;
|
||||||
|
|
||||||
|
public AutoRelaunchDialog(string versionName, int graceSeconds,
|
||||||
|
Func<IReadOnlyList<string>>? healthSnapshot = null)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_versionName = versionName;
|
||||||
|
_graceSeconds = Math.Clamp(graceSeconds, 1, 60);
|
||||||
|
_remainingSeconds = _graceSeconds;
|
||||||
|
_healthSnapshot = healthSnapshot;
|
||||||
|
|
||||||
|
// Si on a un callback santé ET qu'au premier check il y a des
|
||||||
|
// indicateurs en attente, on démarre en phase santé. Sinon on saute
|
||||||
|
// direct au countdown (cas WaitForHealthChecks=false OU tout est
|
||||||
|
// déjà OK au moment où le dialog s'ouvre).
|
||||||
|
var initialPending = _healthSnapshot?.Invoke() ?? Array.Empty<string>();
|
||||||
|
if (initialPending.Count > 0)
|
||||||
|
{
|
||||||
|
_phase = Phase.HealthWait;
|
||||||
|
EnterHealthWaitPhase(initialPending);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_phase = Phase.Countdown;
|
||||||
|
EnterCountdownPhase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un seul timer pour les deux phases : poll santé toutes les 1s ou
|
||||||
|
// décrémente le countdown toutes les 1s. Simplifie le cleanup.
|
||||||
|
_timer = new DispatcherTimer(DispatcherPriority.Normal)
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(1),
|
||||||
|
};
|
||||||
|
_timer.Tick += OnTick;
|
||||||
|
_timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnterHealthWaitPhase(IReadOnlyList<string> pending)
|
||||||
|
{
|
||||||
|
TitleText.Text = Strings.AutoLaunchHealthWaitTitle;
|
||||||
|
MessageText.Text = Strings.AutoLaunchHealthWaitMessage;
|
||||||
|
HealthPendingText.Visibility = Visibility.Visible;
|
||||||
|
CountdownText.Visibility = Visibility.Collapsed;
|
||||||
|
UpdatePendingText(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnterCountdownPhase()
|
||||||
|
{
|
||||||
|
TitleText.Text = Strings.AutoRelaunchTitle;
|
||||||
|
MessageText.Text = Strings.AutoRelaunchMessage(_versionName);
|
||||||
|
HealthPendingText.Visibility = Visibility.Collapsed;
|
||||||
|
CountdownText.Visibility = Visibility.Visible;
|
||||||
|
_remainingSeconds = _graceSeconds;
|
||||||
|
UpdateCountdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdatePendingText(IReadOnlyList<string> pending)
|
||||||
|
{
|
||||||
|
// Ligne 1 : "Indicateurs en attente : N". Ligne 2 : noms séparés par ' • '.
|
||||||
|
// On limite à 4 noms affichés pour ne pas casser le layout si l'opérateur
|
||||||
|
// a configuré 15 checks. Le tooltip système couvre le détail complet.
|
||||||
|
var header = Strings.AutoLaunchHealthWaitPending(pending.Count);
|
||||||
|
var preview = pending.Count <= 4
|
||||||
|
? string.Join(" • ", pending)
|
||||||
|
: string.Join(" • ", pending.Take(4)) + " …";
|
||||||
|
HealthPendingText.Text = $"{header}\n{preview}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_phase == Phase.HealthWait)
|
||||||
|
{
|
||||||
|
var pending = _healthSnapshot?.Invoke() ?? Array.Empty<string>();
|
||||||
|
if (pending.Count == 0)
|
||||||
|
{
|
||||||
|
// Transition vers countdown : tout est OK maintenant.
|
||||||
|
_phase = Phase.Countdown;
|
||||||
|
EnterCountdownPhase();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UpdatePendingText(pending);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase.Countdown
|
||||||
|
_remainingSeconds--;
|
||||||
|
if (_remainingSeconds <= 0)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
DialogResult = true; // lancement
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UpdateCountdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCountdown()
|
||||||
|
{
|
||||||
|
CountdownText.Text = _remainingSeconds.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancel(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
DialogResult = false; // annulation
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
base.OnClosed(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml
Normal file
263
src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<Window x:Class="PSLauncher.App.Views.HealthCheckEditorDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||||
|
Title="{x:Static loc:Strings.HealthEditorTitle}"
|
||||||
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
|
Width="540" Height="640"
|
||||||
|
MinWidth="480" MinHeight="540"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{StaticResource Brush.Bg.Window}"
|
||||||
|
ResizeMode="CanResize">
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Border Grid.Row="0" Padding="24,20,24,12">
|
||||||
|
<TextBlock x:Name="TitleText"
|
||||||
|
Text="{x:Static loc:Strings.HealthEditorTitle}"
|
||||||
|
FontSize="20" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="24,0,24,12">
|
||||||
|
<StackPanel>
|
||||||
|
|
||||||
|
<!-- Nom -->
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorName}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
<TextBox x:Name="NameBox"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorNameHint}"
|
||||||
|
FontSize="11" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,4,0,12" />
|
||||||
|
|
||||||
|
<!-- Type de check -->
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorKind}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
|
||||||
|
<RadioButton x:Name="KindProcess"
|
||||||
|
GroupName="Kind"
|
||||||
|
Content="{x:Static loc:Strings.HealthEditorKindProcess}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Margin="0,0,20,0"
|
||||||
|
Checked="OnKindChanged" />
|
||||||
|
<RadioButton x:Name="KindPing"
|
||||||
|
GroupName="Kind"
|
||||||
|
Content="{x:Static loc:Strings.HealthEditorKindPing}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Margin="0,0,20,0"
|
||||||
|
Checked="OnKindChanged" />
|
||||||
|
<RadioButton x:Name="KindVrDevice"
|
||||||
|
GroupName="Kind"
|
||||||
|
Content="{x:Static loc:Strings.HealthEditorKindVrDevice}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Checked="OnKindChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Cible (process name, IP/host, ou alias VR) -->
|
||||||
|
<TextBlock x:Name="TargetLabel"
|
||||||
|
Text="{x:Static loc:Strings.HealthEditorTargetProcess}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
<TextBox x:Name="TargetBox"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8"
|
||||||
|
FontFamily="Consolas" />
|
||||||
|
|
||||||
|
<!-- ComboBox d'alias VR (visible uniquement si Kind=VrDevice) -->
|
||||||
|
<ComboBox x:Name="VrDeviceCombo"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8"
|
||||||
|
DisplayMemberPath="Label"
|
||||||
|
SelectedValuePath="Alias"
|
||||||
|
Visibility="Collapsed" />
|
||||||
|
|
||||||
|
<TextBlock x:Name="TargetHint"
|
||||||
|
Text="{x:Static loc:Strings.HealthEditorTargetProcessHint}"
|
||||||
|
FontSize="11" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,4,0,12" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<!-- Refresh interval -->
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorRefresh}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
<Grid Margin="0,0,0,4">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="120" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox x:Name="RefreshMsBox"
|
||||||
|
Grid.Column="0"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8" />
|
||||||
|
<TextBlock Grid.Column="1" VerticalAlignment="Center"
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
Text="ms"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorRefreshHint}"
|
||||||
|
FontSize="11" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,12" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<!-- Picto picker (grille d'emojis) -->
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorIcon}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
<Border Background="#1A1A20"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" CornerRadius="4" Padding="6">
|
||||||
|
<ItemsControl x:Name="IconPicker">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel Orientation="Horizontal" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Button Click="OnIconClicked"
|
||||||
|
Tag="{Binding Emoji}"
|
||||||
|
Content="{Binding Emoji}"
|
||||||
|
FontSize="22"
|
||||||
|
Width="40" Height="40"
|
||||||
|
Margin="2"
|
||||||
|
BorderThickness="2"
|
||||||
|
Cursor="Hand">
|
||||||
|
<Button.Style>
|
||||||
|
<Style TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="#0F1117" />
|
||||||
|
<Setter Property="BorderBrush" Value="#2A2F3A" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border CornerRadius="4"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="#1F2530" />
|
||||||
|
<Setter Property="BorderBrush" Value="#3B82F6" />
|
||||||
|
</Trigger>
|
||||||
|
<DataTrigger Binding="{Binding IsSelected}" Value="True">
|
||||||
|
<Setter Property="Background" Value="#1B2A40" />
|
||||||
|
<Setter Property="BorderBrush" Value="#3B82F6" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Button.Style>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorIconHint}"
|
||||||
|
FontSize="11" FontStyle="Italic"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
Margin="0,4,0,12" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<!-- Section Avancé : seuils Ping. Visible uniquement si Kind=Ping. -->
|
||||||
|
<Expander x:Name="PingAdvanced"
|
||||||
|
Header="{x:Static loc:Strings.HealthEditorPingAdvanced}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
Margin="0,4,0,0" IsExpanded="False"
|
||||||
|
Visibility="Collapsed">
|
||||||
|
<Border Background="{StaticResource Brush.Bg.Card}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="16" Margin="0,8,0,0">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorPingWarn}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,0,0,4" />
|
||||||
|
<TextBox x:Name="PingWarnBox"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8"
|
||||||
|
Width="120" HorizontalAlignment="Left" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorPingError}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,12,0,4" />
|
||||||
|
<TextBox x:Name="PingErrorBox"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8"
|
||||||
|
Width="120" HorizontalAlignment="Left" />
|
||||||
|
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.HealthEditorPingTimeout}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" Margin="0,12,0,4" />
|
||||||
|
<TextBox x:Name="PingTimeoutBox"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="1" Padding="8"
|
||||||
|
Width="120" HorizontalAlignment="Left" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Expander>
|
||||||
|
|
||||||
|
<TextBlock x:Name="ErrorText"
|
||||||
|
Foreground="#F87171"
|
||||||
|
FontSize="12"
|
||||||
|
Margin="0,12,0,0"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
|
||||||
|
Padding="20,12">
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.ActionCancel}"
|
||||||
|
IsCancel="True"
|
||||||
|
Margin="0,0,12,0"
|
||||||
|
Click="OnCancel" />
|
||||||
|
<Button Style="{StaticResource AccentButton}"
|
||||||
|
Content="{x:Static loc:Strings.ActionSave}"
|
||||||
|
Padding="32,8"
|
||||||
|
IsDefault="True"
|
||||||
|
Click="OnSave" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
227
src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml.cs
Normal file
227
src/PSLauncher.App/Views/HealthCheckEditorDialog.xaml.cs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modal d'édition d'une entrée du bandeau de santé. Présenté soit pour création
|
||||||
|
/// (constructeur sans argument), soit pour modification (constructeur avec entry
|
||||||
|
/// existante). À la sauvegarde, applique les valeurs sur l'objet d'origine pour
|
||||||
|
/// que la liste parente n'ait rien à reconstruire.
|
||||||
|
///
|
||||||
|
/// Choix UI : icon picker en grille avec 28 emojis curatés couvrant les usages
|
||||||
|
/// VR/streaming/réseau (les checks typiques de PROSERVE). On évite la saisie
|
||||||
|
/// libre d'un emoji pour ne pas se retrouver avec des caractères qui ne rendent
|
||||||
|
/// pas bien dans la pill du bandeau.
|
||||||
|
/// </summary>
|
||||||
|
public partial class HealthCheckEditorDialog : Window
|
||||||
|
{
|
||||||
|
/// <summary>Emojis proposés dans le picker. Listés du plus probable au moins probable.</summary>
|
||||||
|
private static readonly string[] CuratedIcons =
|
||||||
|
{
|
||||||
|
"🎮", "🥽", "📡", "🌐", "📶", "🔌", "💻", "🖥",
|
||||||
|
"📷", "🎧", "🛡", "🔥", "⚙", "🚀", "🔧", "📦",
|
||||||
|
"🎯", "🏃", "🚨", "🔋", "💾", "🔍", "📊", "🖱",
|
||||||
|
"⌨", "🎚", "🟢", "🔵",
|
||||||
|
};
|
||||||
|
|
||||||
|
public HealthCheckEntry Entry { get; }
|
||||||
|
public bool Saved { get; private set; }
|
||||||
|
|
||||||
|
private readonly ObservableCollection<IconOption> _icons = new();
|
||||||
|
|
||||||
|
public HealthCheckEditorDialog() : this(new HealthCheckEntry
|
||||||
|
{
|
||||||
|
Name = "",
|
||||||
|
Icon = "🔌",
|
||||||
|
Kind = "Process",
|
||||||
|
Target = "",
|
||||||
|
RefreshIntervalMs = 5000,
|
||||||
|
}, isNew: true)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aliases acceptés par OpenVrService pour le picker VrDevice.</summary>
|
||||||
|
private static readonly VrAliasOption[] VrAliases =
|
||||||
|
{
|
||||||
|
new("hmd", "HMD"),
|
||||||
|
new("left_controller", "Manette gauche"),
|
||||||
|
new("right_controller", "Manette droite"),
|
||||||
|
new("tracker_1", "Tracker #1"),
|
||||||
|
new("tracker_2", "Tracker #2"),
|
||||||
|
new("tracker_3", "Tracker #3"),
|
||||||
|
new("base_station_1", "Base station #1"),
|
||||||
|
new("base_station_2", "Base station #2"),
|
||||||
|
};
|
||||||
|
|
||||||
|
public HealthCheckEditorDialog(HealthCheckEntry entry, bool isNew = false)
|
||||||
|
{
|
||||||
|
Entry = entry;
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
TitleText.Text = isNew ? Strings.HealthEditorTitleNew : Strings.HealthEditorTitle;
|
||||||
|
|
||||||
|
// Charge les valeurs courantes
|
||||||
|
NameBox.Text = entry.Name ?? "";
|
||||||
|
TargetBox.Text = entry.Target ?? "";
|
||||||
|
RefreshMsBox.Text = entry.RefreshIntervalMs.ToString();
|
||||||
|
|
||||||
|
VrDeviceCombo.ItemsSource = VrAliases;
|
||||||
|
|
||||||
|
var kind = (entry.Kind ?? "Process").Trim().ToLowerInvariant();
|
||||||
|
var isPing = kind == "ping";
|
||||||
|
var isVrDevice = kind == "vrdevice";
|
||||||
|
var isProcess = !isPing && !isVrDevice;
|
||||||
|
KindProcess.IsChecked = isProcess;
|
||||||
|
KindPing.IsChecked = isPing;
|
||||||
|
KindVrDevice.IsChecked = isVrDevice;
|
||||||
|
|
||||||
|
if (isVrDevice)
|
||||||
|
{
|
||||||
|
// Sélectionne l'alias correspondant ; fallback "hmd" si Target inconnu
|
||||||
|
var alias = string.IsNullOrEmpty(entry.Target) ? "hmd" : entry.Target.Trim().ToLowerInvariant();
|
||||||
|
var match = VrAliases.FirstOrDefault(a => string.Equals(a.Alias, alias, StringComparison.OrdinalIgnoreCase));
|
||||||
|
VrDeviceCombo.SelectedValue = match?.Alias ?? "hmd";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
VrDeviceCombo.SelectedValue = "hmd";
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateKindUi(kind);
|
||||||
|
|
||||||
|
PingWarnBox.Text = (entry.PingWarnMs ?? 50).ToString();
|
||||||
|
PingErrorBox.Text = (entry.PingErrorMs ?? 200).ToString();
|
||||||
|
PingTimeoutBox.Text = (entry.PingTimeoutMs ?? 1000).ToString();
|
||||||
|
|
||||||
|
// Construit le picker, marque l'icône courante comme sélectionnée
|
||||||
|
var current = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon;
|
||||||
|
var iconList = CuratedIcons.ToList();
|
||||||
|
if (!iconList.Contains(current)) iconList.Insert(0, current); // garde une icône custom si déjà en config
|
||||||
|
foreach (var ic in iconList)
|
||||||
|
_icons.Add(new IconOption(ic) { IsSelected = ic == current });
|
||||||
|
IconPicker.ItemsSource = _icons;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateKindUi(string kind)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case "ping":
|
||||||
|
TargetLabel.Text = Strings.HealthEditorTargetPing;
|
||||||
|
TargetHint.Text = Strings.HealthEditorTargetPingHint;
|
||||||
|
TargetBox.Visibility = Visibility.Visible;
|
||||||
|
VrDeviceCombo.Visibility = Visibility.Collapsed;
|
||||||
|
PingAdvanced.Visibility = Visibility.Visible;
|
||||||
|
break;
|
||||||
|
case "vrdevice":
|
||||||
|
TargetLabel.Text = Strings.HealthEditorTargetVrDevice;
|
||||||
|
TargetHint.Text = Strings.HealthEditorTargetVrDeviceHint;
|
||||||
|
TargetBox.Visibility = Visibility.Collapsed;
|
||||||
|
VrDeviceCombo.Visibility = Visibility.Visible;
|
||||||
|
PingAdvanced.Visibility = Visibility.Collapsed;
|
||||||
|
break;
|
||||||
|
default: // process
|
||||||
|
TargetLabel.Text = Strings.HealthEditorTargetProcess;
|
||||||
|
TargetHint.Text = Strings.HealthEditorTargetProcessHint;
|
||||||
|
TargetBox.Visibility = Visibility.Visible;
|
||||||
|
VrDeviceCombo.Visibility = Visibility.Collapsed;
|
||||||
|
PingAdvanced.Visibility = Visibility.Collapsed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnKindChanged(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (TargetLabel is null) return; // pendant l'init avant que tout soit chargé
|
||||||
|
var kind = KindPing.IsChecked == true ? "ping"
|
||||||
|
: KindVrDevice.IsChecked == true ? "vrdevice"
|
||||||
|
: "process";
|
||||||
|
UpdateKindUi(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnIconClicked(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Button btn || btn.Tag is not string emoji) return;
|
||||||
|
foreach (var ic in _icons) ic.IsSelected = ic.Emoji == emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSave(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var name = NameBox.Text?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
ShowError(Strings.HealthEditorErrorName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(RefreshMsBox.Text?.Trim(), out var refreshMs) || refreshMs < 200)
|
||||||
|
{
|
||||||
|
ShowError(Strings.HealthEditorErrorRefresh);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedIcon = _icons.FirstOrDefault(i => i.IsSelected)?.Emoji ?? Entry.Icon;
|
||||||
|
|
||||||
|
Entry.Name = name;
|
||||||
|
Entry.Icon = string.IsNullOrEmpty(selectedIcon) ? "🔌" : selectedIcon;
|
||||||
|
Entry.Kind = KindPing.IsChecked == true ? "Ping"
|
||||||
|
: KindVrDevice.IsChecked == true ? "VrDevice"
|
||||||
|
: "Process";
|
||||||
|
|
||||||
|
// Target dépend du Kind : pour VrDevice, on prend la valeur de la combo
|
||||||
|
// (alias ASCII stable comme "hmd" / "left_controller" — ne pas localiser).
|
||||||
|
Entry.Target = Entry.Kind == "VrDevice"
|
||||||
|
? (VrDeviceCombo.SelectedValue as string ?? "hmd")
|
||||||
|
: (TargetBox.Text?.Trim() ?? "");
|
||||||
|
|
||||||
|
Entry.RefreshIntervalMs = refreshMs;
|
||||||
|
|
||||||
|
if (Entry.Kind == "Ping")
|
||||||
|
{
|
||||||
|
Entry.PingWarnMs = TryParseNullableInt(PingWarnBox.Text);
|
||||||
|
Entry.PingErrorMs = TryParseNullableInt(PingErrorBox.Text);
|
||||||
|
Entry.PingTimeoutMs = TryParseNullableInt(PingTimeoutBox.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Saved = true;
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancel(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = false;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowError(string msg)
|
||||||
|
{
|
||||||
|
ErrorText.Text = msg;
|
||||||
|
ErrorText.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? TryParseNullableInt(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||||
|
return int.TryParse(s.Trim(), out var v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Élément du picker d'icônes — emoji + état sélectionné pour le data trigger.</summary>
|
||||||
|
public sealed partial class IconOption : ObservableObject
|
||||||
|
{
|
||||||
|
public string Emoji { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isSelected;
|
||||||
|
|
||||||
|
public IconOption(string emoji) { Emoji = emoji; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Item du ComboBox VrDevice — couple alias technique + libellé affiché.</summary>
|
||||||
|
public sealed record VrAliasOption(string Alias, string Label);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<Window x:Class="PSLauncher.App.Views.LauncherUpdateDialog"
|
<Window x:Class="PSLauncher.App.Views.LauncherUpdateDialog"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Mise à jour du launcher"
|
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||||
|
Title="{x:Static loc:Strings.LauncherUpdateTitle}"
|
||||||
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
Width="500" Height="320"
|
Width="500" Height="320"
|
||||||
ResizeMode="NoResize"
|
ResizeMode="NoResize"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0"
|
<TextBlock Grid.Row="0"
|
||||||
Text="Mise à jour du launcher disponible"
|
Text="{x:Static loc:Strings.LauncherUpdateAvailable}"
|
||||||
FontSize="20" FontWeight="SemiBold"
|
FontSize="20" FontWeight="SemiBold"
|
||||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@
|
|||||||
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Card}"
|
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Card}"
|
||||||
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
CornerRadius="6" Padding="16" Margin="0,16,0,0">
|
CornerRadius="6" Padding="16" Margin="0,16,0,0">
|
||||||
<TextBlock Text="Le launcher va se mettre à jour : il téléchargera le nouveau binaire, se fermera, sera remplacé, puis se relancera automatiquement. Cela prend quelques secondes."
|
<TextBlock Text="{x:Static loc:Strings.LauncherUpdateBody}"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</Border>
|
</Border>
|
||||||
@@ -40,11 +42,11 @@
|
|||||||
<StackPanel Grid.Row="3" Orientation="Horizontal"
|
<StackPanel Grid.Row="3" Orientation="Horizontal"
|
||||||
HorizontalAlignment="Right" Margin="0,18,0,0">
|
HorizontalAlignment="Right" Margin="0,18,0,0">
|
||||||
<Button Style="{StaticResource SecondaryButton}"
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
Content="Plus tard" IsCancel="True"
|
Content="{x:Static loc:Strings.ActionLater}" IsCancel="True"
|
||||||
Margin="0,0,12,0"
|
Margin="0,0,12,0"
|
||||||
Click="OnLater" />
|
Click="OnLater" />
|
||||||
<Button Style="{StaticResource AccentButton}"
|
<Button Style="{StaticResource AccentButton}"
|
||||||
Content="⬇ Mettre à jour"
|
Content="{x:Static loc:Strings.LauncherUpdateNow}"
|
||||||
Padding="32,8"
|
Padding="32,8"
|
||||||
IsDefault="True"
|
IsDefault="True"
|
||||||
Click="OnUpdate" />
|
Click="OnUpdate" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.App.Views;
|
namespace PSLauncher.App.Views;
|
||||||
@@ -12,7 +13,7 @@ public partial class LauncherUpdateDialog : Window
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
VersionText.Text = $"v{currentVersion} → v{info.Version}";
|
VersionText.Text = $"v{currentVersion} → v{info.Version}";
|
||||||
SizeText.Text = info.Download.SizeBytes > 0
|
SizeText.Text = info.Download.SizeBytes > 0
|
||||||
? $"Taille : {FormatSize(info.Download.SizeBytes)}"
|
? Strings.SizeLabel(Strings.FormatSize(info.Download.SizeBytes))
|
||||||
: "";
|
: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,13 +30,4 @@ public partial class LauncherUpdateDialog : Window
|
|||||||
DialogResult = false;
|
DialogResult = false;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatSize(long bytes)
|
|
||||||
{
|
|
||||||
if (bytes <= 0) return "—";
|
|
||||||
string[] units = { "o", "Ko", "Mo", "Go" };
|
|
||||||
double v = bytes; int u = 0;
|
|
||||||
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
|
|
||||||
return $"{v:0.#} {units[u]}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<Window x:Class="PSLauncher.App.Views.LicenseDetailsDialog"
|
<Window x:Class="PSLauncher.App.Views.LicenseDetailsDialog"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||||
Title="License"
|
Title="License"
|
||||||
Width="500" Height="480"
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
|
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}">
|
||||||
@@ -14,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 -->
|
||||||
@@ -44,7 +58,7 @@
|
|||||||
<ColumnDefinition Width="160" />
|
<ColumnDefinition Width="160" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0" Text="Client"
|
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsClient}"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
|
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
|
||||||
<TextBlock Grid.Column="1" x:Name="OwnerText"
|
<TextBlock Grid.Column="1" x:Name="OwnerText"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
@@ -55,7 +69,7 @@
|
|||||||
<ColumnDefinition Width="160" />
|
<ColumnDefinition Width="160" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0" Text="Validité téléchargements"
|
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsValidity}"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
|
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
|
||||||
<TextBlock Grid.Column="1" x:Name="UntilText"
|
<TextBlock Grid.Column="1" x:Name="UntilText"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
@@ -66,7 +80,7 @@
|
|||||||
<ColumnDefinition Width="160" />
|
<ColumnDefinition Width="160" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0" Text="Activée le"
|
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsActivatedOn}"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
|
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
|
||||||
<TextBlock Grid.Column="1" x:Name="IssuedText"
|
<TextBlock Grid.Column="1" x:Name="IssuedText"
|
||||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
@@ -76,7 +90,7 @@
|
|||||||
<ColumnDefinition Width="160" />
|
<ColumnDefinition Width="160" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0" Text="ID machine"
|
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsMachineId}"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12"
|
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<Grid Grid.Column="1">
|
<Grid Grid.Column="1">
|
||||||
@@ -90,7 +104,7 @@
|
|||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
<Button Grid.Column="1" Style="{StaticResource SecondaryButton}"
|
<Button Grid.Column="1" Style="{StaticResource SecondaryButton}"
|
||||||
Content="📋"
|
Content="📋"
|
||||||
ToolTip="Copier"
|
ToolTip="{x:Static loc:Strings.CopyTooltip}"
|
||||||
Click="OnCopyMachineId" />
|
Click="OnCopyMachineId" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -100,11 +114,11 @@
|
|||||||
<!-- Footer actions -->
|
<!-- Footer actions -->
|
||||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
|
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
|
||||||
<Button Style="{StaticResource SecondaryButton}"
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
Content="🗑 Désactiver la license"
|
Content="{x:Static loc:Strings.LicenseDetailsDeactivate}"
|
||||||
Click="OnDeactivate"
|
Click="OnDeactivate"
|
||||||
Margin="0,0,12,0" />
|
Margin="0,0,12,0" />
|
||||||
<Button Style="{StaticResource AccentButton}"
|
<Button Style="{StaticResource AccentButton}"
|
||||||
Content="Fermer"
|
Content="{x:Static loc:Strings.ActionClose}"
|
||||||
IsCancel="True" IsDefault="True"
|
IsCancel="True" IsDefault="True"
|
||||||
Padding="32,8"
|
Padding="32,8"
|
||||||
Click="OnClose" />
|
Click="OnClose" />
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.App.Views;
|
namespace PSLauncher.App.Views;
|
||||||
@@ -28,11 +29,11 @@ public partial class LicenseDetailsDialog : Window
|
|||||||
|
|
||||||
OwnerText.Text = license.OwnerName ?? "—";
|
OwnerText.Text = license.OwnerName ?? "—";
|
||||||
UntilText.Text = license.DownloadEntitlementUntil is { } until
|
UntilText.Text = license.DownloadEntitlementUntil is { } until
|
||||||
? $"jusqu'au {until.ToLocalTime():dddd dd MMMM yyyy}"
|
? Strings.FormatLongDate(until)
|
||||||
: "—";
|
: "—";
|
||||||
IssuedText.Text = license.IssuedAt is { } issued
|
IssuedText.Text = license.IssuedAt is { } issued
|
||||||
? issued.ToLocalTime().ToString("dd/MM/yyyy")
|
? Strings.FormatDate(issued)
|
||||||
: (license.ServerTime?.ToLocalTime().ToString("dd/MM/yyyy") ?? "—");
|
: Strings.FormatDate(license.ServerTime);
|
||||||
MachineIdText.Text = licenseService.GetMachineId();
|
MachineIdText.Text = licenseService.GetMachineId();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,19 +43,19 @@ public partial class LicenseDetailsDialog : Window
|
|||||||
if (l.Status == "valid")
|
if (l.Status == "valid")
|
||||||
{
|
{
|
||||||
if (until is { } u && (u - DateTime.UtcNow).TotalDays < 30)
|
if (until is { } u && (u - DateTime.UtcNow).TotalDays < 30)
|
||||||
return ("⏰", "License valide", $"Expire bientôt — le {u.ToLocalTime():dd/MM/yyyy}",
|
return ("⏰", Strings.LicenseDetailsValidTitle, Strings.LicenseDetailsExpiringSoon(Strings.FormatDate(u)),
|
||||||
Color.FromRgb(0xF5, 0x9E, 0x0B), Color.FromRgb(0x3A, 0x2A, 0x0E));
|
Color.FromRgb(0xF5, 0x9E, 0x0B), Color.FromRgb(0x3A, 0x2A, 0x0E));
|
||||||
return ("✓", "License valide", "Téléchargements de nouvelles versions autorisés",
|
return ("✓", Strings.LicenseDetailsValidTitle, Strings.LicenseDetailsValidSubtitle,
|
||||||
Color.FromRgb(0x16, 0xA3, 0x4A), Color.FromRgb(0x0E, 0x2A, 0x1B));
|
Color.FromRgb(0x16, 0xA3, 0x4A), Color.FromRgb(0x0E, 0x2A, 0x1B));
|
||||||
}
|
}
|
||||||
if (l.Status == "expired")
|
if (l.Status == "expired")
|
||||||
return ("⚠", "License expirée",
|
return ("⚠", Strings.LicenseDetailsExpiredTitle,
|
||||||
until is { } u ? $"Expirée le {u.ToLocalTime():dd/MM/yyyy}" : "Plus de téléchargements possibles",
|
until is { } u ? Strings.LicenseDetailsExpiredSubtitle(Strings.FormatDate(u)) : Strings.LicenseDetailsExpiredFallback,
|
||||||
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
|
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
|
||||||
if (l.Status == "revoked")
|
if (l.Status == "revoked")
|
||||||
return ("🚫", "License révoquée", "Contacte ASTERION pour plus d'informations",
|
return ("🚫", Strings.LicenseDetailsRevokedTitle, Strings.LicenseDetailsRevokedSubtitle,
|
||||||
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
|
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
|
||||||
return ("❓", "License inconnue", l.Message ?? l.Status,
|
return ("❓", Strings.LicenseDetailsUnknownTitle, l.Message ?? l.Status,
|
||||||
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
|
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,11 +66,9 @@ 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(
|
||||||
"Désactiver la license sur cette machine ?\n\n" +
|
Strings.MsgDeactivateLicenseDetailedConfirm,
|
||||||
"Tu pourras la réactiver plus tard avec ta clé. " +
|
Strings.MsgBoxConfirm,
|
||||||
"Les versions déjà installées restent utilisables.",
|
|
||||||
"Confirmer la désactivation",
|
|
||||||
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||||||
if (confirm != MessageBoxResult.Yes) return;
|
if (confirm != MessageBoxResult.Yes) return;
|
||||||
_licenseService.Clear();
|
_licenseService.Clear();
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
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: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"
|
||||||
Background="Black"
|
Background="Black"
|
||||||
Width="1280" Height="720"
|
Width="1280" Height="720"
|
||||||
MinWidth="980" MinHeight="600"
|
MinWidth="980" MinHeight="600"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
|
WindowState="Maximized"
|
||||||
WindowStyle="None"
|
WindowStyle="None"
|
||||||
ResizeMode="CanResize"
|
ResizeMode="CanResize"
|
||||||
AllowsTransparency="False"
|
AllowsTransparency="False"
|
||||||
@@ -53,22 +57,23 @@
|
|||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- Strip latérale colorée -->
|
<!-- Strip latérale colorée : Border avec coins arrondis à gauche
|
||||||
<Rectangle Grid.Column="0" RadiusX="2" RadiusY="2" Margin="0,8">
|
pour suivre la rondeur (CornerRadius=6) de la card parent. -->
|
||||||
<Rectangle.Style>
|
<Border Grid.Column="0" CornerRadius="5,0,0,5">
|
||||||
<Style TargetType="Rectangle">
|
<Border.Style>
|
||||||
<Setter Property="Fill" Value="{StaticResource Brush.Status.Installed}" />
|
<Style TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Brush.Status.Installed}" />
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<DataTrigger Binding="{Binding IsRemoteOnly}" Value="True">
|
<DataTrigger Binding="{Binding IsRemoteOnly}" Value="True">
|
||||||
<Setter Property="Fill" Value="{StaticResource Brush.Status.Available}" />
|
<Setter Property="Background" Value="{StaticResource Brush.Status.Available}" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<DataTrigger Binding="{Binding IsBusy}" Value="True">
|
<DataTrigger Binding="{Binding IsBusy}" Value="True">
|
||||||
<Setter Property="Fill" Value="{StaticResource Brush.Status.Busy}" />
|
<Setter Property="Background" Value="{StaticResource Brush.Status.Busy}" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</Rectangle.Style>
|
</Border.Style>
|
||||||
</Rectangle>
|
</Border>
|
||||||
|
|
||||||
<Grid Grid.Column="1" Margin="16,12">
|
<Grid Grid.Column="1" Margin="16,12">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
@@ -80,6 +85,31 @@
|
|||||||
FontSize="15" FontWeight="SemiBold"
|
FontSize="15" FontWeight="SemiBold"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<!-- Badge BÊTA pour les rows secondaires (compact). Même look
|
||||||
|
que celui du featured mais en plus petit pour matcher la row. -->
|
||||||
|
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="#F59E0B"
|
||||||
|
ToolTip="{Binding BetaTooltip}"
|
||||||
|
Visibility="{Binding IsBeta, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
|
||||||
|
FontSize="10" FontWeight="Bold"
|
||||||
|
Foreground="#1A0A00" />
|
||||||
|
</Border>
|
||||||
|
<!-- Badge CHANNEL : violet, à côté des autres badges. Affiché
|
||||||
|
quand la row est sur un channel spécifique (non-default) et
|
||||||
|
qu'une autre row visible partage le même numéro de version.
|
||||||
|
Couleur violette #8B5CF6 pour se distinguer nettement du
|
||||||
|
badge BETA orange et du bleu du statut "Available". -->
|
||||||
|
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="#8B5CF6"
|
||||||
|
ToolTip="{Binding ChannelBadge}"
|
||||||
|
Visibility="{Binding HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<TextBlock Text="{Binding ChannelBadge}"
|
||||||
|
FontSize="10" FontWeight="Bold"
|
||||||
|
Foreground="White" />
|
||||||
|
</Border>
|
||||||
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
|
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
@@ -110,14 +140,43 @@
|
|||||||
|
|
||||||
<!-- Action button -->
|
<!-- Action button -->
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0">
|
||||||
|
<!-- Bouton AUTO : sélectionne cette version pour l'auto-launch
|
||||||
|
au démarrage du launcher. Fond gris quand inactif, orange
|
||||||
|
quand sélectionné. Visible seulement si FeatureEnabled. -->
|
||||||
|
<Button Content="{x:Static loc:Strings.ActionAuto}"
|
||||||
|
Padding="14,8" FontSize="13" FontWeight="Bold"
|
||||||
|
Margin="0,0,6,0"
|
||||||
|
Command="{Binding ToggleAutoModeCommand}"
|
||||||
|
Visibility="{Binding ShowAutoButton, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<Button.Style>
|
||||||
|
<Style TargetType="Button" BasedOn="{StaticResource SecondaryButton}">
|
||||||
|
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipInactive}" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsAutoModeSelected}" Value="True">
|
||||||
|
<Setter Property="Background" Value="#F5A623" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipActive}" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Button.Style>
|
||||||
|
</Button>
|
||||||
<Button Style="{StaticResource PrimaryButton}"
|
<Button Style="{StaticResource PrimaryButton}"
|
||||||
Content="▶ Lancer" Padding="22,8" FontSize="13"
|
Content="{x:Static loc:Strings.ActionLaunch}" Padding="22,8" FontSize="13"
|
||||||
Command="{Binding LaunchCommand}"
|
Command="{Binding LaunchCommand}"
|
||||||
Visibility="{Binding IsInstalled, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding IsInstalled, Converter={StaticResource BoolToVisibility}}" />
|
||||||
<Button Style="{StaticResource AccentButton}"
|
<Button Style="{StaticResource AccentButton}"
|
||||||
Content="{Binding InstallButtonLabel}" Padding="22,8" FontSize="13"
|
Content="{Binding InstallButtonLabel}" Padding="22,8" FontSize="13"
|
||||||
Command="{Binding InstallCommand}"
|
Command="{Binding InstallCommand}"
|
||||||
Visibility="{Binding IsRemoteOnly, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding IsRemoteOnly, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<!-- Bouton rouge « Annuler » : visible uniquement quand on a un partial.
|
||||||
|
Permet d'abandonner le partial et repartir de zéro (avec confirmation). -->
|
||||||
|
<Button Style="{StaticResource DangerButton}"
|
||||||
|
Content="{x:Static loc:Strings.ActionCancel}"
|
||||||
|
Padding="14,8" FontSize="13" Margin="6,0,0,0"
|
||||||
|
Command="{Binding RestartFromZeroCommand}"
|
||||||
|
ToolTip="{x:Static loc:Strings.MenuRestartFromZero}"
|
||||||
|
Visibility="{Binding ShowRestartFromZero, Converter={StaticResource BoolToVisibility}}" />
|
||||||
<StackPanel Orientation="Horizontal"
|
<StackPanel Orientation="Horizontal"
|
||||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}">
|
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}">
|
||||||
<ProgressBar Width="140" Height="6"
|
<ProgressBar Width="140" Height="6"
|
||||||
@@ -141,13 +200,18 @@
|
|||||||
Click="OnMoreMenuClick">
|
Click="OnMoreMenuClick">
|
||||||
<Button.ContextMenu>
|
<Button.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<MenuItem Header="📜 Voir les release notes"
|
<MenuItem Header="{x:Static loc:Strings.MenuReleaseNotes}"
|
||||||
Command="{Binding PlacementTarget.Tag.ShowReleaseNotesCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
Command="{Binding PlacementTarget.Tag.ShowReleaseNotesCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
||||||
<MenuItem Header="📁 Ouvrir le dossier"
|
<MenuItem Header="{x:Static loc:Strings.MenuOpenFolder}"
|
||||||
Command="{Binding PlacementTarget.Tag.OpenFolderCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.OpenFolderCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
||||||
|
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
|
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
|
||||||
|
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
||||||
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
<MenuItem Header="🗑 Supprimer cette version…"
|
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
||||||
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
@@ -158,25 +222,59 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</Window.Resources>
|
</Window.Resources>
|
||||||
|
|
||||||
<Grid Background="Black">
|
<!--
|
||||||
|
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="{StaticResource Brush.Bg.Window}">
|
||||||
|
<!-- La fenêtre est correctement clampée à la work area du moniteur en mode
|
||||||
|
Maximized via WM_GETMINMAXINFO (cf. MainWindow.xaml.cs). Plus besoin
|
||||||
|
de hack via Margin sur trigger WindowState. -->
|
||||||
|
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="200" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" /> <!-- Top bar -->
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="Auto" /> <!-- Health banner -->
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="*" /> <!-- Body (sidebar + content) -->
|
||||||
|
<RowDefinition Height="Auto" /> <!-- Footer -->
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Background image (sous tout le reste, ne capte pas la souris).
|
<!-- Background image : limité à la zone content (Row 2, Col 1) pour ne pas
|
||||||
Le fond noir du Grid garantit que les zones transparentes de l'image
|
passer derrière la sidebar, le banner ou le footer. -->
|
||||||
(et l'image dans son ensemble à faible opacity) reposent sur du noir
|
<Rectangle Grid.Row="2" Grid.Column="1" IsHitTestVisible="False">
|
||||||
pur, sans héritage du brush système. -->
|
<Rectangle.Fill>
|
||||||
<Image Grid.Row="0" Grid.RowSpan="3"
|
<ImageBrush ImageSource="pack://application:,,,/Resources/Background.png"
|
||||||
Source="pack://application:,,,/Resources/Background.png"
|
Stretch="UniformToFill"
|
||||||
Stretch="UniformToFill"
|
AlignmentX="Right"
|
||||||
Opacity="0.25"
|
AlignmentY="Bottom"
|
||||||
IsHitTestVisible="False" />
|
Opacity="0.25" />
|
||||||
|
</Rectangle.Fill>
|
||||||
|
</Rectangle>
|
||||||
|
<Rectangle Grid.Row="2" Grid.Column="1"
|
||||||
|
Fill="{StaticResource Brush.Bg.BlueTint}"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -187,6 +285,12 @@
|
|||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<!-- Col 1 : brand -->
|
<!-- Col 1 : brand -->
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Image Source="pack://application:,,,/Resources/logo-asterion-white.png"
|
||||||
|
Height="36"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Margin="0,0,14,0"
|
||||||
|
SnapsToDevicePixels="True"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality" />
|
||||||
<TextBlock Text="PROSERVE"
|
<TextBlock Text="PROSERVE"
|
||||||
FontFamily="{StaticResource Font.Brand}"
|
FontFamily="{StaticResource Font.Brand}"
|
||||||
FontSize="22"
|
FontSize="22"
|
||||||
@@ -206,7 +310,7 @@
|
|||||||
Cursor="Hand"
|
Cursor="Hand"
|
||||||
CornerRadius="16" Padding="14,6"
|
CornerRadius="16" Padding="14,6"
|
||||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||||
ToolTip="Voir / changer la license">
|
ToolTip="{x:Static loc:Strings.LicenseTooltip}">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
<Style TargetType="Border">
|
<Style TargetType="Border">
|
||||||
<Setter Property="Background" Value="#2A1414" />
|
<Setter Property="Background" Value="#2A1414" />
|
||||||
@@ -270,28 +374,79 @@
|
|||||||
<StackPanel Grid.Column="2" Orientation="Horizontal"
|
<StackPanel Grid.Column="2" Orientation="Horizontal"
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||||
<Button Style="{StaticResource SecondaryButton}"
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
Content="⚙ Paramètres"
|
Content="{x:Static loc:Strings.TopBarSettings}"
|
||||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||||
Command="{Binding OpenSettingsCommand}"
|
Command="{Binding OpenSettingsCommand}"
|
||||||
Margin="0,0,16,0" />
|
Margin="0,0,16,0" />
|
||||||
<Button Style="{StaticResource WindowControlButton}"
|
<Button Style="{StaticResource WindowControlButton}"
|
||||||
Content="—" ToolTip="Réduire"
|
Content="—" ToolTip="{x:Static loc:Strings.Minimize}"
|
||||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||||
Click="OnMinimizeClick" />
|
Click="OnMinimizeClick" />
|
||||||
<Button Style="{StaticResource WindowControlButton}"
|
<Button Style="{StaticResource WindowControlButton}"
|
||||||
Content="☐" ToolTip="Agrandir / Restaurer"
|
Content="☐" ToolTip="{x:Static loc:Strings.Maximize}"
|
||||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||||
Click="OnMaxRestoreClick" />
|
Click="OnMaxRestoreClick" />
|
||||||
<Button Style="{StaticResource WindowCloseButton}"
|
<Button Style="{StaticResource WindowCloseButton}"
|
||||||
Content="✕" ToolTip="Fermer"
|
Content="✕" ToolTip="{x:Static loc:Strings.CloseTooltip}"
|
||||||
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
shell:WindowChrome.IsHitTestVisibleInChrome="True"
|
||||||
Click="OnCloseClick" />
|
Click="OnCloseClick" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Body : Grid pour pouvoir superposer le bouton flottant "Vérifier les MAJ" -->
|
<!-- ============== Health banner (Row 1, Col 1 uniquement) ==============
|
||||||
<Grid Grid.Row="1">
|
Pills colorées indiquant l'état des dépendances système (SteamVR,
|
||||||
|
Vive Streaming, ping casque…). Le banner ne couvre QUE la colonne
|
||||||
|
content — la sidebar à gauche s'étend du sous-topbar jusqu'en bas
|
||||||
|
en RowSpan=3 (banner + body + footer) pour rester visuellement le
|
||||||
|
chapeau de tout. Visible uniquement sur la page Library. -->
|
||||||
|
<Border Grid.Row="1" Grid.Column="1"
|
||||||
|
Background="{StaticResource Brush.Bg.Sidebar}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
Padding="16,8"
|
||||||
|
Visibility="{Binding ShowHealthBanner, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<ItemsControl ItemsSource="{Binding HealthIndicators}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="{Binding StatusBrush}"
|
||||||
|
BorderBrush="{Binding BorderBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="10,4"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
ToolTip="{Binding Tooltip}"
|
||||||
|
ToolTipService.InitialShowDelay="200"
|
||||||
|
ToolTipService.ShowDuration="20000">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock Text="{Binding Icon}"
|
||||||
|
FontSize="14"
|
||||||
|
Foreground="{Binding IconForeground}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="{Binding Name}"
|
||||||
|
Margin="6,0,0,0"
|
||||||
|
FontSize="12"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============== Content area (Row 2, Col 1, swap par CurrentPage) ==============
|
||||||
|
Superpose plusieurs blocs (Library / Report / Documentation) gérés par
|
||||||
|
leur Visibility liée à CurrentPage. -->
|
||||||
|
<Grid Grid.Row="2" 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">
|
||||||
|
|
||||||
@@ -321,22 +476,25 @@
|
|||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- Bande latérale large -->
|
<!-- Bande latérale large : Border avec coins arrondis seulement à
|
||||||
<Rectangle Grid.Column="0">
|
gauche pour suivre la rondeur de la card parent (CornerRadius=10).
|
||||||
<Rectangle.Style>
|
Rectangle ne se clippe pas sur le parent ⇒ on utilisait un visuel
|
||||||
<Style TargetType="Rectangle">
|
qui dépassait les coins. -->
|
||||||
<Setter Property="Fill" Value="{StaticResource Brush.Status.Installed}" />
|
<Border Grid.Column="0" CornerRadius="9,0,0,9">
|
||||||
|
<Border.Style>
|
||||||
|
<Style TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Brush.Status.Installed}" />
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<DataTrigger Binding="{Binding FeaturedVersion.IsRemoteOnly}" Value="True">
|
<DataTrigger Binding="{Binding FeaturedVersion.IsRemoteOnly}" Value="True">
|
||||||
<Setter Property="Fill" Value="{StaticResource Brush.Status.Available}" />
|
<Setter Property="Background" Value="{StaticResource Brush.Status.Available}" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
<DataTrigger Binding="{Binding FeaturedVersion.IsBusy}" Value="True">
|
<DataTrigger Binding="{Binding FeaturedVersion.IsBusy}" Value="True">
|
||||||
<Setter Property="Fill" Value="{StaticResource Brush.Status.Busy}" />
|
<Setter Property="Background" Value="{StaticResource Brush.Status.Busy}" />
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</Rectangle.Style>
|
</Border.Style>
|
||||||
</Rectangle>
|
</Border>
|
||||||
|
|
||||||
<Grid Grid.Column="1" Margin="32,28">
|
<Grid Grid.Column="1" Margin="32,28">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
@@ -352,16 +510,42 @@
|
|||||||
|
|
||||||
<!-- Label "Version courante" -->
|
<!-- Label "Version courante" -->
|
||||||
<TextBlock Grid.Row="0" Grid.Column="0"
|
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||||
Text="VERSION COURANTE"
|
Text="{x:Static loc:Strings.FeaturedCurrent}"
|
||||||
FontSize="11" FontWeight="Bold"
|
FontSize="11" FontWeight="Bold"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
Margin="0,0,0,4" />
|
Margin="0,0,0,4" />
|
||||||
|
|
||||||
<!-- Numéro version + badge -->
|
<!-- Numéro version + badge -->
|
||||||
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
<TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='Proserve v{0}'}"
|
<TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='PROSERVE v{0}'}"
|
||||||
FontSize="32" FontWeight="Bold"
|
FontSize="32" FontWeight="Bold"
|
||||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
|
<!-- Badge BÊTA : pill orange à côté de la version. Visible
|
||||||
|
uniquement quand isBeta=true dans le manifest. Tooltip
|
||||||
|
dynamique avec les notes des testeurs s'il y en a. -->
|
||||||
|
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="#F59E0B"
|
||||||
|
ToolTip="{Binding FeaturedVersion.BetaTooltip}"
|
||||||
|
Visibility="{Binding FeaturedVersion.IsBeta, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="#1A0A00" />
|
||||||
|
</Border>
|
||||||
|
<!-- Badge CHANNEL : pill violette. Visible sur les entrées
|
||||||
|
manifest taggées avec un channel non-default (firefighter,
|
||||||
|
police, etc.) quand une autre row partage le numéro de
|
||||||
|
version. Violet #8B5CF6 pour se distinguer du orange BETA
|
||||||
|
et du bleu du statut "Available". -->
|
||||||
|
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Background="#8B5CF6"
|
||||||
|
ToolTip="{Binding FeaturedVersion.ChannelBadge}"
|
||||||
|
Visibility="{Binding FeaturedVersion.HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<TextBlock Text="{Binding FeaturedVersion.ChannelBadge}"
|
||||||
|
FontSize="11" FontWeight="Bold"
|
||||||
|
Foreground="White" />
|
||||||
|
</Border>
|
||||||
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
|
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<Border.Style>
|
<Border.Style>
|
||||||
@@ -387,28 +571,59 @@
|
|||||||
FontSize="13"
|
FontSize="13"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
Margin="0,8,0,0">
|
Margin="0,8,0,0">
|
||||||
<Run Text="Sortie le " />
|
<Run Text="{x:Static loc:Strings.ReleasedOn}" />
|
||||||
<Run Text="{Binding FeaturedVersion.PrimaryDate, Mode=OneWay}" />
|
<Run Text="{Binding FeaturedVersion.PrimaryDate, Mode=OneWay}" />
|
||||||
<Run Text=" • " />
|
<Run Text=" • " />
|
||||||
<Run Text="{Binding FeaturedVersion.SizeDisplay, Mode=OneWay}" />
|
<Run Text="{Binding FeaturedVersion.SizeDisplay, Mode=OneWay}" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
|
|
||||||
<!-- Action principale (gros bouton) -->
|
<!-- Action principale (gros bouton) -->
|
||||||
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
||||||
Style="{StaticResource PrimaryButton}"
|
Orientation="Horizontal" VerticalAlignment="Center"
|
||||||
Content="▶ LANCER"
|
Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}">
|
||||||
FontSize="20" Padding="56,16"
|
<!-- Bouton AUTO featured (taille assortie au gros LANCER) -->
|
||||||
VerticalAlignment="Center"
|
<Button Content="{x:Static loc:Strings.ActionAuto}"
|
||||||
Command="{Binding FeaturedVersion.LaunchCommand}"
|
FontSize="16" FontWeight="Bold"
|
||||||
Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}" />
|
Padding="20,16"
|
||||||
|
Margin="0,0,12,0"
|
||||||
|
Command="{Binding FeaturedVersion.ToggleAutoModeCommand}"
|
||||||
|
Visibility="{Binding FeaturedVersion.ShowAutoButton, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<Button.Style>
|
||||||
|
<Style TargetType="Button" BasedOn="{StaticResource SecondaryButton}">
|
||||||
|
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipInactive}" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding FeaturedVersion.IsAutoModeSelected}" Value="True">
|
||||||
|
<Setter Property="Background" Value="#F5A623" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipActive}" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Button.Style>
|
||||||
|
</Button>
|
||||||
|
<Button Style="{StaticResource PrimaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.ActionLaunchBig}"
|
||||||
|
FontSize="20" Padding="56,16"
|
||||||
|
Command="{Binding FeaturedVersion.LaunchCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
||||||
Style="{StaticResource AccentButton}"
|
Orientation="Vertical" VerticalAlignment="Center"
|
||||||
Content="{Binding FeaturedVersion.InstallButtonLabel}"
|
Visibility="{Binding FeaturedVersion.IsRemoteOnly, Converter={StaticResource BoolToVisibility}}">
|
||||||
FontSize="20" Padding="48,16"
|
<Button Style="{StaticResource AccentButton}"
|
||||||
VerticalAlignment="Center"
|
Content="{Binding FeaturedVersion.InstallButtonLabel}"
|
||||||
Command="{Binding FeaturedVersion.InstallCommand}"
|
FontSize="20" Padding="48,16"
|
||||||
Visibility="{Binding FeaturedVersion.IsRemoteOnly, Converter={StaticResource BoolToVisibility}}" />
|
Command="{Binding FeaturedVersion.InstallCommand}" />
|
||||||
|
<!-- Bouton rouge « Annuler » : visible uniquement quand un partial existe.
|
||||||
|
Permet d'abandonner et repartir de zéro (confirmation requise). -->
|
||||||
|
<Button Style="{StaticResource DangerButton}"
|
||||||
|
Content="{x:Static loc:Strings.ActionCancel}"
|
||||||
|
FontSize="13" Padding="20,8" Margin="0,8,0,0"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
Command="{Binding FeaturedVersion.RestartFromZeroCommand}"
|
||||||
|
ToolTip="{x:Static loc:Strings.MenuRestartFromZero}"
|
||||||
|
Visibility="{Binding FeaturedVersion.ShowRestartFromZero, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
|
||||||
Orientation="Vertical" VerticalAlignment="Center" Width="220"
|
Orientation="Vertical" VerticalAlignment="Center" Width="220"
|
||||||
@@ -438,13 +653,18 @@
|
|||||||
Click="OnMoreMenuClick">
|
Click="OnMoreMenuClick">
|
||||||
<Button.ContextMenu>
|
<Button.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<MenuItem Header="📜 Voir les release notes"
|
<MenuItem Header="{x:Static loc:Strings.MenuReleaseNotes}"
|
||||||
Command="{Binding PlacementTarget.Tag.ShowReleaseNotesCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
Command="{Binding PlacementTarget.Tag.ShowReleaseNotesCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
||||||
<MenuItem Header="📁 Ouvrir le dossier"
|
<MenuItem Header="{x:Static loc:Strings.MenuOpenFolder}"
|
||||||
Command="{Binding PlacementTarget.Tag.OpenFolderCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.OpenFolderCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
|
||||||
|
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
|
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
|
||||||
|
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
|
||||||
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
<MenuItem Header="🗑 Supprimer cette version…"
|
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
|
||||||
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
|
||||||
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
@@ -461,7 +681,7 @@
|
|||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="0" Text="AUTRES VERSIONS"
|
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.SectionOther}"
|
||||||
FontSize="12" FontWeight="Bold"
|
FontSize="12" FontWeight="Bold"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
@@ -492,13 +712,168 @@
|
|||||||
<Button HorizontalAlignment="Left" VerticalAlignment="Bottom"
|
<Button HorizontalAlignment="Left" VerticalAlignment="Bottom"
|
||||||
Margin="32,0,0,24"
|
Margin="32,0,0,24"
|
||||||
Style="{StaticResource SecondaryButton}"
|
Style="{StaticResource SecondaryButton}"
|
||||||
Content="🔄 Vérifier les MAJ"
|
Content="{x:Static loc:Strings.TopBarCheckUpdates}"
|
||||||
Command="{Binding CheckForUpdatesCommand}" />
|
Command="{Binding CheckForUpdatesCommand}" />
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Footer : visible uniquement quand busy / status à afficher.
|
<!-- Copyright flottant en bas centré, sur une pill sombre pour rester
|
||||||
Le bouton "Vérifier les MAJ" a été déplacé en flottant au-dessus du body. -->
|
lisible quel que soit l'arrière-plan (image, voile noir, etc.).
|
||||||
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
|
Bottom margin volontairement faible pour rester collé au footer
|
||||||
|
(barre de DL) quand il s'affiche. -->
|
||||||
|
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
|
||||||
|
Margin="0,0,0,8"
|
||||||
|
Background="#A0000000"
|
||||||
|
CornerRadius="10"
|
||||||
|
Padding="10,4">
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.Copyright}"
|
||||||
|
FontSize="11"
|
||||||
|
Foreground="White" />
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
<!-- ====================== END LIBRARY PAGE ====================== -->
|
||||||
|
|
||||||
|
<!-- ====================== REPORT PAGE (WebView2) ====================== -->
|
||||||
|
<Grid Background="{StaticResource Brush.Bg.Window}"
|
||||||
|
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 + RowSpan=3 → la sidebar s'étend du bas du top bar jusqu'au
|
||||||
|
bas de la fenêtre, couvrant les 3 rows (health banner, content,
|
||||||
|
footer). Visuellement la sidebar reste le « chapeau de tout »
|
||||||
|
côté gauche, peu importe ce qui se passe à droite. -->
|
||||||
|
<Border Grid.Row="1" Grid.RowSpan="3" Grid.Column="0"
|
||||||
|
Background="#0A0A0E"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="0,0,1,0">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<!-- Bloc d'info en bas de sidebar : version du launcher + IP locale.
|
||||||
|
Utilité diagnostic terrain : le support peut lire les deux infos
|
||||||
|
sans avoir à naviguer dans Windows ou About. DockPanel.Dock=Bottom
|
||||||
|
pour rester collé au bas peu importe la hauteur de la fenêtre. -->
|
||||||
|
<Border DockPanel.Dock="Bottom"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
Padding="16,10">
|
||||||
|
<!-- Grid 2 colonnes : la colonne label (Auto) prend la largeur du
|
||||||
|
plus long ("PS_Launcher:"), la colonne valeur démarre au même X
|
||||||
|
sur les deux lignes → version et IP visuellement alignées. -->
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||||
|
Text="PS_Launcher"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" Margin="0,0,8,0"
|
||||||
|
ToolTip="Version du launcher" />
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1"
|
||||||
|
Text="{Binding AppVersion}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" />
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0"
|
||||||
|
Text="IP"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" Margin="0,2,8,0"
|
||||||
|
ToolTip="IPv4 locale du PC (interface avec gateway par défaut)" />
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="1"
|
||||||
|
Text="{Binding LocalIpAddress}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" Margin="0,2,0,0" />
|
||||||
|
<!-- Statut LAN P2P, refresh auto toutes les 10 s par DispatcherTimer
|
||||||
|
dans MainViewModel. ON / OFF (—) / KO (config ON mais bind échoué). -->
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0"
|
||||||
|
Text="Serveur"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" Margin="0,2,8,0"
|
||||||
|
ToolTip="État du serveur LAN P2P (partage des ZIPs aux peers du LAN)" />
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="1"
|
||||||
|
Text="{Binding LanServerStatus}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" Margin="0,2,0,0" />
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0"
|
||||||
|
Text="Client"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" Margin="0,2,8,0"
|
||||||
|
ToolTip="Mode client : tente d'aller chercher les ZIPs sur le LAN avant OVH" />
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="1"
|
||||||
|
Text="{Binding LanClientStatus}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" Margin="0,2,0,0" />
|
||||||
|
<TextBlock Grid.Row="4" Grid.Column="0"
|
||||||
|
Text="Peers"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" Margin="0,2,8,0"
|
||||||
|
ToolTip="Nombre de peers LAN découverts auto via UDP (TTL 60 s)" />
|
||||||
|
<TextBlock Grid.Row="4" Grid.Column="1"
|
||||||
|
Text="{Binding LanPeerCount}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="11" Margin="0,2,0,0" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Footer : Row 3, Col 1 → cantonné à la colonne content, sous la zone
|
||||||
|
Library/Report/Doc. La sidebar (RowSpan=2 sur rows 2-3) le contourne
|
||||||
|
par sa colonne. -->
|
||||||
|
<Border Grid.Row="3" 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}">
|
||||||
@@ -518,10 +893,10 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="1"
|
<Button Grid.Column="1"
|
||||||
Style="{StaticResource SecondaryButton}"
|
Style="{StaticResource SecondaryButton}"
|
||||||
Content="Annuler"
|
Content="{x:Static loc:Strings.ActionCancel}"
|
||||||
VerticalAlignment="Center" Margin="12,0,0,0"
|
VerticalAlignment="Center" Margin="12,0,0,0"
|
||||||
Command="{Binding CancelDownloadCommand}"
|
Command="{Binding CancelDownloadCommand}"
|
||||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
Visibility="{Binding IsDownloadActive, Converter={StaticResource BoolToVisibility}}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,14 +1,164 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Controls.Primitives;
|
using System.Windows.Controls.Primitives;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using Microsoft.Web.WebView2.Wpf;
|
||||||
|
using PSLauncher.App.ViewModels;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
|
|
||||||
namespace PSLauncher.App.Views;
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
|
private static readonly int WM_PSLAUNCHER_BRING = RegisterWindowMessage("PSLAUNCHER_BRING_TO_FRONT");
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern int RegisterWindowMessage(string lpString);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||||
|
|
||||||
|
private const int SW_RESTORE = 9;
|
||||||
|
|
||||||
|
// ===== WM_GETMINMAXINFO : contraint la taille en mode Maximized à la work area =====
|
||||||
|
// Avec WindowStyle="None" + WindowChrome, Windows maximise la fenêtre à la taille
|
||||||
|
// PHYSIQUE du moniteur (incluant la zone de la taskbar), ce qui fait passer le footer
|
||||||
|
// sous la barre des tâches → barre de DL invisible. Le fix canonique est de hooker
|
||||||
|
// WM_GETMINMAXINFO et de remplacer ptMaxSize/ptMaxPosition par les coordonnées de
|
||||||
|
// la work area (rcWork) du moniteur sur lequel la fenêtre se trouve. Multi-écran-safe
|
||||||
|
// (gère même les taskbars sur écran secondaire) et insensible au DPI.
|
||||||
|
private const int WM_GETMINMAXINFO = 0x0024;
|
||||||
|
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr MonitorFromWindow(IntPtr handle, uint flags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct POINT { public int X; public int Y; }
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct RECT { public int Left, Top, Right, Bottom; }
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MINMAXINFO
|
||||||
|
{
|
||||||
|
public POINT ptReserved;
|
||||||
|
public POINT ptMaxSize;
|
||||||
|
public POINT ptMaxPosition;
|
||||||
|
public POINT ptMinTrackSize;
|
||||||
|
public POINT ptMaxTrackSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MONITORINFO
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public RECT rcMonitor;
|
||||||
|
public RECT rcWork;
|
||||||
|
public uint dwFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HandleGetMinMaxInfo(IntPtr hwnd, IntPtr lParam)
|
||||||
|
{
|
||||||
|
var monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||||||
|
if (monitor == IntPtr.Zero) return;
|
||||||
|
|
||||||
|
var info = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
|
||||||
|
if (!GetMonitorInfo(monitor, ref info)) return;
|
||||||
|
|
||||||
|
var mmi = Marshal.PtrToStructure<MINMAXINFO>(lParam);
|
||||||
|
var work = info.rcWork;
|
||||||
|
var monitorRect = info.rcMonitor;
|
||||||
|
mmi.ptMaxPosition.X = Math.Abs(work.Left - monitorRect.Left);
|
||||||
|
mmi.ptMaxPosition.Y = Math.Abs(work.Top - monitorRect.Top);
|
||||||
|
mmi.ptMaxSize.X = Math.Abs(work.Right - work.Left);
|
||||||
|
mmi.ptMaxSize.Y = Math.Abs(work.Bottom - work.Top);
|
||||||
|
Marshal.StructureToPtr(mmi, lParam, true);
|
||||||
|
}
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
SourceInitialized += MainWindow_SourceInitialized;
|
||||||
|
Closing += MainWindow_Closing;
|
||||||
|
|
||||||
|
// Force un reload de la WebView quand son onglet redevient visible
|
||||||
|
// (Library → Report ou Library → Doc), pour que l'utilisateur voie
|
||||||
|
// toujours les données les plus à jour côté serveur PHP. Sans ça,
|
||||||
|
// WebView2 garde la page en cache de la première navigation et ne
|
||||||
|
// refresh jamais. Au tout premier affichage CoreWebView2 n'est pas
|
||||||
|
// encore initialisée — le binding Source fait alors la nav initiale,
|
||||||
|
// les reloads suivants kicker uniquement sur les changements d'onglet.
|
||||||
|
ReportWebView.IsVisibleChanged += OnWebViewBecameVisible;
|
||||||
|
DocsWebView.IsVisibleChanged += OnWebViewBecameVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnWebViewBecameVisible(object sender, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.NewValue is true && sender is WebView2 wv && wv.CoreWebView2 is not null)
|
||||||
|
{
|
||||||
|
wv.CoreWebView2.Reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Si un DL est en cours, demande confirmation avant de fermer le launcher.
|
||||||
|
/// La progression est de toute façon préservée pour reprise au prochain lancement,
|
||||||
|
/// mais on évite à l'utilisateur de cliquer ✕ par accident en plein milieu d'un 14 Go.
|
||||||
|
/// </summary>
|
||||||
|
private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is MainViewModel vm && vm.HasActiveDownload)
|
||||||
|
{
|
||||||
|
var version = vm.ActiveDownloadVersion ?? "?";
|
||||||
|
var result = ThemedMessageBox.Show(
|
||||||
|
Strings.MsgQuitWhileDownloadingConfirm(version),
|
||||||
|
Strings.MsgBoxQuit,
|
||||||
|
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
|
||||||
|
if (result != MessageBoxResult.Yes) e.Cancel = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hook le pump de messages Windows pour réagir au broadcast envoyé par une
|
||||||
|
/// seconde instance qui veut nous mettre au premier plan.
|
||||||
|
/// </summary>
|
||||||
|
private void MainWindow_SourceInitialized(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
var helper = new WindowInteropHelper(this);
|
||||||
|
var source = HwndSource.FromHwnd(helper.Handle);
|
||||||
|
source?.AddHook(WndProc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||||
|
{
|
||||||
|
if (msg == WM_PSLAUNCHER_BRING)
|
||||||
|
{
|
||||||
|
if (WindowState == WindowState.Minimized)
|
||||||
|
WindowState = WindowState.Normal;
|
||||||
|
else
|
||||||
|
ShowWindow(hwnd, SW_RESTORE);
|
||||||
|
Activate();
|
||||||
|
SetForegroundWindow(hwnd);
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
else if (msg == WM_GETMINMAXINFO)
|
||||||
|
{
|
||||||
|
HandleGetMinMaxInfo(hwnd, lParam);
|
||||||
|
// On laisse handled=false : Windows continue son traitement avec le MINMAXINFO
|
||||||
|
// qu'on vient de réécrire (clamp à la work area du moniteur).
|
||||||
|
}
|
||||||
|
return IntPtr.Zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<Window x:Class="PSLauncher.App.Views.OnboardingDialog"
|
<Window x:Class="PSLauncher.App.Views.OnboardingDialog"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Activation PROSERVE Launcher"
|
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||||
|
Title="{x:Static loc:Strings.OnboardingTitle}"
|
||||||
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
Width="540" Height="420"
|
Width="540" Height="420"
|
||||||
MinWidth="480" MinHeight="380"
|
MinWidth="480" MinHeight="380"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
@@ -17,17 +19,17 @@
|
|||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Activer votre license"
|
<TextBlock Grid.Row="0" Text="{x:Static loc:Strings.OnboardingHeading}"
|
||||||
FontSize="22" FontWeight="SemiBold"
|
FontSize="22" FontWeight="SemiBold"
|
||||||
Foreground="{StaticResource Brush.Text.Primary}" />
|
Foreground="{StaticResource Brush.Text.Primary}" />
|
||||||
|
|
||||||
<TextBlock Grid.Row="1"
|
<TextBlock Grid.Row="1"
|
||||||
Text="Saisis la clé fournie par ASTERION. Elle te donne accès aux téléchargements jusqu'à la date de validité associée."
|
Text="{x:Static loc:Strings.OnboardingBody}"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}"
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
Margin="0,8,0,18" />
|
Margin="0,8,0,18" />
|
||||||
|
|
||||||
<TextBlock Grid.Row="2" Text="Clé de license"
|
<TextBlock Grid.Row="2" Text="{x:Static loc:Strings.OnboardingLicenseKey}"
|
||||||
FontSize="12" FontWeight="Bold"
|
FontSize="12" FontWeight="Bold"
|
||||||
Foreground="{StaticResource Brush.Text.Secondary}" />
|
Foreground="{StaticResource Brush.Text.Secondary}" />
|
||||||
<TextBox Grid.Row="3" x:Name="KeyBox"
|
<TextBox Grid.Row="3" x:Name="KeyBox"
|
||||||
@@ -51,12 +53,12 @@
|
|||||||
|
|
||||||
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
|
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
|
||||||
<Button Style="{StaticResource SecondaryButton}"
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
Content="Plus tard"
|
Content="{x:Static loc:Strings.ActionLater}"
|
||||||
IsCancel="True"
|
IsCancel="True"
|
||||||
Margin="0,0,12,0"
|
Margin="0,0,12,0"
|
||||||
Click="OnLater" />
|
Click="OnLater" />
|
||||||
<Button Style="{StaticResource AccentButton}"
|
<Button Style="{StaticResource AccentButton}"
|
||||||
Content="Activer"
|
Content="{x:Static loc:Strings.OnboardingActivate}"
|
||||||
Padding="32,10"
|
Padding="32,10"
|
||||||
IsDefault="True"
|
IsDefault="True"
|
||||||
Click="OnActivate"
|
Click="OnActivate"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Media;
|
using System.Windows.Media;
|
||||||
using PSLauncher.Core.Licensing;
|
using PSLauncher.Core.Licensing;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
|
|
||||||
namespace PSLauncher.App.Views;
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
@@ -24,12 +25,12 @@ public partial class OnboardingDialog : Window
|
|||||||
var key = KeyBox.Text?.Trim().ToUpperInvariant() ?? string.Empty;
|
var key = KeyBox.Text?.Trim().ToUpperInvariant() ?? string.Empty;
|
||||||
if (string.IsNullOrEmpty(key) || key.Contains("XXXX"))
|
if (string.IsNullOrEmpty(key) || key.Contains("XXXX"))
|
||||||
{
|
{
|
||||||
ShowStatus("Saisis une clé valide au format PRSRV-XXXX-XXXX-XXXX-XXXX.", isError: true);
|
ShowStatus(Strings.OnboardingInvalidKey, isError: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ActivateButton.IsEnabled = false;
|
ActivateButton.IsEnabled = false;
|
||||||
ShowStatus("Validation en cours…", isError: false);
|
ShowStatus(Strings.OnboardingValidating, isError: false);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -38,7 +39,8 @@ public partial class OnboardingDialog : Window
|
|||||||
{
|
{
|
||||||
case "valid":
|
case "valid":
|
||||||
_licenseService.SaveCached(key, resp);
|
_licenseService.SaveCached(key, resp);
|
||||||
ShowStatus($"License activée ({resp.OwnerName}). Téléchargements autorisés jusqu'au {resp.DownloadEntitlementUntil:dd/MM/yyyy}.", isError: false);
|
ShowStatus(Strings.OnboardingActivated(resp.OwnerName ?? "—",
|
||||||
|
Strings.FormatDate(resp.DownloadEntitlementUntil)), isError: false);
|
||||||
LicenseActivated = true;
|
LicenseActivated = true;
|
||||||
DialogResult = true;
|
DialogResult = true;
|
||||||
Close();
|
Close();
|
||||||
@@ -46,27 +48,27 @@ public partial class OnboardingDialog : Window
|
|||||||
|
|
||||||
case "expired":
|
case "expired":
|
||||||
_licenseService.SaveCached(key, resp); // on cache quand même pour permettre de lancer les versions installées
|
_licenseService.SaveCached(key, resp); // on cache quand même pour permettre de lancer les versions installées
|
||||||
ShowStatus($"License expirée le {resp.DownloadEntitlementUntil:dd/MM/yyyy}. Tu peux toujours utiliser les versions déjà installées, mais plus en télécharger de nouvelles.", isError: true);
|
ShowStatus(Strings.OnboardingExpired(Strings.FormatDate(resp.DownloadEntitlementUntil)), isError: true);
|
||||||
LicenseActivated = true;
|
LicenseActivated = true;
|
||||||
DialogResult = true;
|
DialogResult = true;
|
||||||
Close();
|
Close();
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case "revoked":
|
case "revoked":
|
||||||
ShowStatus("Cette license a été révoquée. Contacte ASTERION.", isError: true);
|
ShowStatus(Strings.OnboardingRevoked, isError: true);
|
||||||
break;
|
break;
|
||||||
case "machine_limit_exceeded":
|
case "machine_limit_exceeded":
|
||||||
ShowStatus(resp.Message ?? "Cette license a atteint son nombre maximum de machines.", isError: true);
|
ShowStatus(resp.Message ?? Strings.OnboardingMachineLimit, isError: true);
|
||||||
break;
|
break;
|
||||||
case "invalid":
|
case "invalid":
|
||||||
default:
|
default:
|
||||||
ShowStatus(resp.Message ?? "Clé de license inconnue.", isError: true);
|
ShowStatus(resp.Message ?? Strings.OnboardingInvalid, isError: true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
ShowStatus($"Erreur de communication avec le serveur :\n{ex.Message}", isError: true);
|
ShowStatus(Strings.OnboardingServerError(ex.Message), isError: true);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<Window x:Class="PSLauncher.App.Views.ReleaseNotesViewerDialog"
|
<Window x:Class="PSLauncher.App.Views.ReleaseNotesViewerDialog"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Notes de version"
|
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||||
|
Title="{x:Static loc:Strings.MsgBoxReleaseNotes}"
|
||||||
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
Width="640" Height="540"
|
Width="640" Height="540"
|
||||||
MinWidth="480" MinHeight="400"
|
MinWidth="480" MinHeight="400"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
@@ -32,7 +34,7 @@
|
|||||||
|
|
||||||
<Button Grid.Row="2"
|
<Button Grid.Row="2"
|
||||||
Style="{StaticResource SecondaryButton}"
|
Style="{StaticResource SecondaryButton}"
|
||||||
Content="Fermer"
|
Content="{x:Static loc:Strings.ActionClose}"
|
||||||
IsCancel="True"
|
IsCancel="True"
|
||||||
IsDefault="True"
|
IsDefault="True"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,10 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using PSLauncher.App.ViewModels;
|
using PSLauncher.App.ViewModels;
|
||||||
|
using PSLauncher.Core.Security;
|
||||||
|
|
||||||
namespace PSLauncher.App.Views;
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
@@ -25,4 +30,80 @@ public partial class SettingsDialog : Window
|
|||||||
DialogResult = false;
|
DialogResult = false;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quand l'opérateur clique pour déplier les paramètres avancés, on vérifie le
|
||||||
|
/// settings lock service. Si verrouillé, on affiche le password prompt. Si Cancel
|
||||||
|
/// ou mauvais mot de passe, on referme l'expander pour que la section reste cachée.
|
||||||
|
///
|
||||||
|
/// Trace écrite dans un fichier dédié (settings-lock.log) à chaque appel pour
|
||||||
|
/// permettre de diagnostiquer les cas "l'expander se déplie pas" sans dépendre
|
||||||
|
/// du Serilog principal (au cas où l'event ne traverserait même pas le code).
|
||||||
|
/// </summary>
|
||||||
|
private void OnAdvancedExpanded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
TraceExpanded("OnAdvancedExpanded fired");
|
||||||
|
if (sender is not Expander exp)
|
||||||
|
{
|
||||||
|
TraceExpanded("sender is not Expander, aborting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Résout le service depuis l'App DI container. Service-locator pattern toléré
|
||||||
|
// ici car SettingsDialog n'est pas injectable directement (instancié par new).
|
||||||
|
var app = (App)Application.Current;
|
||||||
|
var lockService = app.HostServices?.GetService<ISettingsLockService>();
|
||||||
|
if (lockService is null)
|
||||||
|
{
|
||||||
|
TraceExpanded("lockService is null, expander stays open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TraceExpanded($"lockService.HasLockConfigured={lockService.HasLockConfigured} IsLocked={lockService.IsLocked}");
|
||||||
|
if (!lockService.IsLocked)
|
||||||
|
{
|
||||||
|
TraceExpanded("not locked, expander stays open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TraceExpanded("locked, showing password dialog");
|
||||||
|
bool? ok = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dialog = new SettingsLockDialog(lockService) { Owner = this };
|
||||||
|
ok = dialog.ShowDialog();
|
||||||
|
TraceExpanded($"dialog result: {ok}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Toute exception (XAML parse error, missing resource, etc.) doit être
|
||||||
|
// logguée et NE PAS rester silencieuse. Sans ce catch, le bug se manifeste
|
||||||
|
// par "rien ne s'ouvre, mais le log dit que ça devrait" (cas 0.28.2 avec
|
||||||
|
// Brush.Status.Danger inexistant).
|
||||||
|
TraceExpanded($"EXCEPTION creating/showing SettingsLockDialog: {ex.GetType().Name}: {ex.Message}");
|
||||||
|
// On laisse l'expander DÉPLIÉ (ok=null tombera dans le if false ci-dessous
|
||||||
|
// qui le refermerait). Plutôt : on garde déplié pour ne pas frustrer l'user
|
||||||
|
// si le lock dialog est cassé — il aura accès, et le bug doit être réglé.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ok != true)
|
||||||
|
{
|
||||||
|
// Cancel ou mauvais mot de passe → on referme l'expander.
|
||||||
|
exp.IsExpanded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TraceExpanded(string msg)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"PSLauncher", "logs");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var path = Path.Combine(dir, "settings-lock.log");
|
||||||
|
File.AppendAllText(path,
|
||||||
|
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z {msg}{Environment.NewLine}");
|
||||||
|
}
|
||||||
|
catch { /* best effort */ }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/PSLauncher.App/Views/SettingsLockDialog.xaml
Normal file
64
src/PSLauncher.App/Views/SettingsLockDialog.xaml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<Window x:Class="PSLauncher.App.Views.SettingsLockDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||||
|
Title="{x:Static loc:Strings.SettingsLockedTitle}"
|
||||||
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
|
Width="520" Height="360"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Topmost="True"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
Background="{StaticResource Brush.Bg.Window}">
|
||||||
|
<Grid Margin="32,28">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0"
|
||||||
|
FontSize="18" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}">
|
||||||
|
<Run Text="🔒 " />
|
||||||
|
<Run Text="{x:Static loc:Strings.SettingsLockedTitle}" />
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1"
|
||||||
|
Text="{x:Static loc:Strings.SettingsLockedBody}"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="12" TextWrapping="Wrap"
|
||||||
|
Margin="0,8,0,12" />
|
||||||
|
|
||||||
|
<PasswordBox Grid.Row="2"
|
||||||
|
x:Name="PasswordInput"
|
||||||
|
Background="#1A1A20"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
|
||||||
|
Padding="8" FontSize="14"
|
||||||
|
KeyDown="OnPasswordKeyDown" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3"
|
||||||
|
x:Name="ErrorText"
|
||||||
|
Foreground="#FCA5A5"
|
||||||
|
FontSize="11"
|
||||||
|
Margin="0,4,0,0"
|
||||||
|
Visibility="Collapsed"
|
||||||
|
Text="{x:Static loc:Strings.SettingsLockedWrongPassword}" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="4" Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||||
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
|
Content="{x:Static loc:Strings.ActionCancel}" IsCancel="True"
|
||||||
|
Margin="0,0,12,0"
|
||||||
|
Click="OnCancel" />
|
||||||
|
<Button Style="{StaticResource AccentButton}"
|
||||||
|
Content="{x:Static loc:Strings.SettingsLockedUnlock}"
|
||||||
|
Padding="20,8"
|
||||||
|
IsDefault="True"
|
||||||
|
Click="OnUnlock" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
77
src/PSLauncher.App/Views/SettingsLockDialog.xaml.cs
Normal file
77
src/PSLauncher.App/Views/SettingsLockDialog.xaml.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using PSLauncher.Core.Security;
|
||||||
|
|
||||||
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dialog de saisie du password qui déverrouille la section Avancés des Settings.
|
||||||
|
/// Le password est hashé en SHA-256 et comparé au hash du manifest signé. L'unlock
|
||||||
|
/// est valable jusqu'à la fermeture du launcher (en-mémoire dans <see cref="ISettingsLockService"/>).
|
||||||
|
///
|
||||||
|
/// DialogResult :
|
||||||
|
/// <c>true</c> = unlock réussi
|
||||||
|
/// <c>false</c> = annulation (Cancel)
|
||||||
|
/// </summary>
|
||||||
|
public partial class SettingsLockDialog : Window
|
||||||
|
{
|
||||||
|
private readonly ISettingsLockService _lock;
|
||||||
|
|
||||||
|
public SettingsLockDialog(ISettingsLockService lockService)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_lock = lockService;
|
||||||
|
Loaded += (_, _) =>
|
||||||
|
{
|
||||||
|
// Trace pour diagnostiquer un éventuel "le dialog ne s'affiche pas".
|
||||||
|
// Si on voit cette ligne dans settings-lock.log, le dialog est rendu
|
||||||
|
// → le pb est ailleurs (hors écran, derrière owner, etc.).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = System.IO.Path.Combine(
|
||||||
|
System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"PSLauncher", "logs");
|
||||||
|
System.IO.Directory.CreateDirectory(dir);
|
||||||
|
System.IO.File.AppendAllText(
|
||||||
|
System.IO.Path.Combine(dir, "settings-lock.log"),
|
||||||
|
$"{System.DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z SettingsLockDialog Loaded (visible={IsVisible}, left={Left}, top={Top}){System.Environment.NewLine}");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
Activate();
|
||||||
|
PasswordInput.Focus();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPasswordKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Enter)
|
||||||
|
{
|
||||||
|
TryUnlock();
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUnlock(object sender, RoutedEventArgs e) => TryUnlock();
|
||||||
|
|
||||||
|
private void TryUnlock()
|
||||||
|
{
|
||||||
|
var ok = _lock.TryUnlock(PasswordInput.Password);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ErrorText.Visibility = Visibility.Visible;
|
||||||
|
PasswordInput.Clear();
|
||||||
|
PasswordInput.Focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancel(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = false;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<Window x:Class="PSLauncher.App.Views.UpdateAvailableDialog"
|
<Window x:Class="PSLauncher.App.Views.UpdateAvailableDialog"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Mise à jour disponible"
|
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
|
||||||
|
Title="{x:Static loc:Strings.UpdateAvailableTitle}"
|
||||||
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
Width="640" Height="540"
|
Width="640" Height="540"
|
||||||
MinWidth="480" MinHeight="400"
|
MinWidth="480" MinHeight="400"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
@@ -12,6 +14,7 @@
|
|||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0"
|
<TextBlock Grid.Row="0"
|
||||||
@@ -35,14 +38,23 @@
|
|||||||
Background="Transparent" />
|
Background="Transparent" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
|
<CheckBox Grid.Row="3"
|
||||||
|
x:Name="PreserveSaveGamesCheckBox"
|
||||||
|
Content="{x:Static loc:Strings.UpdatePreserveSaveGames}"
|
||||||
|
ToolTip="{x:Static loc:Strings.UpdatePreserveSaveGamesTooltip}"
|
||||||
|
IsChecked="True"
|
||||||
|
Foreground="{StaticResource Brush.Text.Primary}"
|
||||||
|
Margin="0,14,0,0"
|
||||||
|
HorizontalAlignment="Left" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
|
||||||
<Button Style="{StaticResource SecondaryButton}"
|
<Button Style="{StaticResource SecondaryButton}"
|
||||||
Content="Plus tard"
|
Content="{x:Static loc:Strings.ActionLater}"
|
||||||
IsCancel="True"
|
IsCancel="True"
|
||||||
Margin="0,0,12,0"
|
Margin="0,0,12,0"
|
||||||
Click="OnLater" />
|
Click="OnLater" />
|
||||||
<Button Style="{StaticResource PrimaryButton}"
|
<Button Style="{StaticResource PrimaryButton}"
|
||||||
Content="⬇ Télécharger"
|
Content="{x:Static loc:Strings.UpdateDownload}"
|
||||||
Padding="32,10"
|
Padding="32,10"
|
||||||
Click="OnDownload" />
|
Click="OnDownload" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.App.Views;
|
namespace PSLauncher.App.Views;
|
||||||
@@ -9,19 +10,32 @@ public partial class UpdateAvailableDialog : Window
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
|
var sizeText = version.Download.SizeBytes > 0
|
||||||
|
? Strings.FormatSize(version.Download.SizeBytes)
|
||||||
|
: Strings.SizeUnknown;
|
||||||
|
|
||||||
DataContext = new
|
DataContext = new
|
||||||
{
|
{
|
||||||
Title = $"Proserve v{version.Version} disponible",
|
Title = Strings.UpdateAvailableHeading(version.Version),
|
||||||
Subtitle = $"Date de release : {version.ReleasedAt.ToLocalTime():dd MMMM yyyy} • {FormatSize(version.Download.SizeBytes)}",
|
Subtitle = Strings.UpdateAvailableSubtitle(Strings.FormatLongDate(version.ReleasedAt), sizeText),
|
||||||
ReleaseNotesDocument = MarkdownTheming.BuildThemedDocument(releaseNotesMarkdown)
|
ReleaseNotesDocument = MarkdownTheming.BuildThemedDocument(releaseNotesMarkdown)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool DownloadRequested { get; private set; }
|
public bool DownloadRequested { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// État de la case « Conserver les sauvegardes de la version précédente ».
|
||||||
|
/// Cochée par défaut, lue par MainViewModel APRÈS l'extraction du ZIP pour
|
||||||
|
/// décider s'il faut copier les .sav depuis l'install précédente vers la
|
||||||
|
/// nouvelle (voir CopyPreviousSaveGamesAsync).
|
||||||
|
/// </summary>
|
||||||
|
public bool PreserveSaveGames { get; private set; } = true;
|
||||||
|
|
||||||
private void OnDownload(object sender, RoutedEventArgs e)
|
private void OnDownload(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
DownloadRequested = true;
|
DownloadRequested = true;
|
||||||
|
PreserveSaveGames = PreserveSaveGamesCheckBox.IsChecked == true;
|
||||||
DialogResult = true;
|
DialogResult = true;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
@@ -32,14 +46,4 @@ public partial class UpdateAvailableDialog : Window
|
|||||||
DialogResult = false;
|
DialogResult = false;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatSize(long bytes)
|
|
||||||
{
|
|
||||||
if (bytes <= 0) return "taille inconnue";
|
|
||||||
string[] units = { "o", "Ko", "Mo", "Go", "To" };
|
|
||||||
double v = bytes;
|
|
||||||
int u = 0;
|
|
||||||
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
|
|
||||||
return $"{v:0.#} {units[u]}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
222
src/PSLauncher.Core/ApiTool/ApiToolDeployer.cs
Normal file
222
src/PSLauncher.Core/ApiTool/ApiToolDeployer.cs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.ApiTool;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implémentation : copy récursif + atomic rename + backups horodatés.
|
||||||
|
/// Strictement parallèle à <see cref="ReportToolDeployer"/> mais avec
|
||||||
|
/// <c>_api/</c> comme source au lieu de <c>_report/</c>. Voir le commentaire
|
||||||
|
/// de cette dernière classe pour les détails du mécanisme.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiToolDeployer : IApiToolDeployer
|
||||||
|
{
|
||||||
|
private const string SourceSubdir = "_api";
|
||||||
|
private const string BackupSuffix = ".backup-";
|
||||||
|
private const string TimestampFormat = "yyyyMMdd-HHmmss";
|
||||||
|
|
||||||
|
private readonly Func<ApiToolConfig> _configProvider;
|
||||||
|
private readonly ILogger<ApiToolDeployer> _logger;
|
||||||
|
|
||||||
|
public ApiToolDeployer(
|
||||||
|
Func<ApiToolConfig> configProvider,
|
||||||
|
ILogger<ApiToolDeployer> 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 api deploy", SourceSubdir, installFolder);
|
||||||
|
return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
|
{
|
||||||
|
// Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(cfg.HtdocsRoot);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
|
||||||
|
cfg.HtdocsRoot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
|
||||||
|
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||||
|
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stagingTarget = target + ".new";
|
||||||
|
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (Directory.Exists(target))
|
||||||
|
Directory.Move(target, backupTarget);
|
||||||
|
Directory.Move(stagingTarget, target);
|
||||||
|
|
||||||
|
PruneOldBackups(cfg);
|
||||||
|
|
||||||
|
_logger.LogInformation("Api 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 api 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;
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 api 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, "Api revert to {Backup} failed", backupFolderName);
|
||||||
|
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PruneOldBackups(ApiToolConfig 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) })
|
||||||
|
.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 api backup {Dir}", dirs[i].Name);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Could not prune api backup {Dir} (will retry next deploy)", dirs[i].Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Api backup pruning skipped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/PSLauncher.Core/ApiTool/IApiToolDeployer.cs
Normal file
25
src/PSLauncher.Core/ApiTool/IApiToolDeployer.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.ApiTool;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Déploie l'API PHP de stats PROSERVE depuis le sous-dossier <c>_api/</c> bundled
|
||||||
|
/// dans chaque ZIP PROSERVE vers le htdocs local. Mêmes garanties que
|
||||||
|
/// <see cref="IReportToolDeployer"/> : copy + atomic rename + backups horodatés
|
||||||
|
/// + revert manuel.
|
||||||
|
///
|
||||||
|
/// On réutilise les types <see cref="DeployResult"/>, <see cref="DeployStatus"/>,
|
||||||
|
/// <see cref="DeployProgress"/> et <see cref="BackupInfo"/> du namespace ReportTool
|
||||||
|
/// pour ne pas dupliquer la modélisation — le mécanisme est strictement le même.
|
||||||
|
/// </summary>
|
||||||
|
public interface IApiToolDeployer
|
||||||
|
{
|
||||||
|
Task<DeployResult> DeployAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<DeployProgress>? progress,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -65,17 +65,21 @@ public sealed class ConfigStore : IConfigStore
|
|||||||
File.Move(tmp, _configFile, overwrite: true);
|
File.Move(tmp, _configFile, overwrite: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cible standard pour les déploiements clients : <c>C:\ASTERION_VR</c>.
|
||||||
|
/// Si un dossier ASTERION_VR existe déjà à côté de l'exe (mode dev / portable),
|
||||||
|
/// il est préféré pour ne pas casser l'environnement de développement.
|
||||||
|
/// </summary>
|
||||||
private static string ResolveDefaultInstallRoot()
|
private static string ResolveDefaultInstallRoot()
|
||||||
{
|
{
|
||||||
|
// Mode dev / portable : ASTERION_VR à côté de l'exe (par ex. dans le repo)
|
||||||
var exeDir = AppContext.BaseDirectory;
|
var exeDir = AppContext.BaseDirectory;
|
||||||
var sibling = Path.Combine(Path.GetDirectoryName(exeDir.TrimEnd(Path.DirectorySeparatorChar))!, "ASTERION_VR");
|
var sibling = Path.Combine(
|
||||||
|
Path.GetDirectoryName(exeDir.TrimEnd(Path.DirectorySeparatorChar))!,
|
||||||
|
"ASTERION_VR");
|
||||||
if (Directory.Exists(sibling)) return sibling;
|
if (Directory.Exists(sibling)) return sibling;
|
||||||
|
|
||||||
var dev = @"C:\ASTERION\GIT\PS_Launcher\ASTERION_VR";
|
// Cible standard pour les clients (créée au premier install si absente)
|
||||||
if (Directory.Exists(dev)) return dev;
|
return @"C:\ASTERION_VR";
|
||||||
|
|
||||||
return Path.Combine(
|
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
||||||
"ASTERION_VR");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
222
src/PSLauncher.Core/DocTool/DocToolDeployer.cs
Normal file
222
src/PSLauncher.Core/DocTool/DocToolDeployer.cs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.DocTool;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implémentation : copy récursif + atomic rename + backups horodatés.
|
||||||
|
/// Strictement parallèle à <see cref="ReportToolDeployer"/> mais avec
|
||||||
|
/// <c>_doc/</c> comme source au lieu de <c>_report/</c>. Voir le commentaire
|
||||||
|
/// de cette dernière classe pour les détails du mécanisme.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DocToolDeployer : IDocToolDeployer
|
||||||
|
{
|
||||||
|
private const string SourceSubdir = "_doc";
|
||||||
|
private const string BackupSuffix = ".backup-";
|
||||||
|
private const string TimestampFormat = "yyyyMMdd-HHmmss";
|
||||||
|
|
||||||
|
private readonly Func<DocToolConfig> _configProvider;
|
||||||
|
private readonly ILogger<DocToolDeployer> _logger;
|
||||||
|
|
||||||
|
public DocToolDeployer(
|
||||||
|
Func<DocToolConfig> configProvider,
|
||||||
|
ILogger<DocToolDeployer> 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 doc deploy", SourceSubdir, installFolder);
|
||||||
|
return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(cfg.HtdocsRoot))
|
||||||
|
{
|
||||||
|
// Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(cfg.HtdocsRoot);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
|
||||||
|
cfg.HtdocsRoot);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
|
||||||
|
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
|
||||||
|
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stagingTarget = target + ".new";
|
||||||
|
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (Directory.Exists(target))
|
||||||
|
Directory.Move(target, backupTarget);
|
||||||
|
Directory.Move(stagingTarget, target);
|
||||||
|
|
||||||
|
PruneOldBackups(cfg);
|
||||||
|
|
||||||
|
_logger.LogInformation("Doc 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 doc 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;
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 doc 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, "Doc revert to {Backup} failed", backupFolderName);
|
||||||
|
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PruneOldBackups(DocToolConfig 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) })
|
||||||
|
.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 doc backup {Dir}", dirs[i].Name);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Could not prune doc backup {Dir} (will retry next deploy)", dirs[i].Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Doc backup pruning skipped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/PSLauncher.Core/DocTool/IDocToolDeployer.cs
Normal file
25
src/PSLauncher.Core/DocTool/IDocToolDeployer.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using PSLauncher.Core.ReportTool;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.DocTool;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Déploie l'outil Documentation (page web hébergée dans XAMPP) depuis le
|
||||||
|
/// sous-dossier <c>_doc/</c> bundled dans chaque ZIP PROSERVE vers le htdocs
|
||||||
|
/// local. Mêmes garanties que <see cref="IReportToolDeployer"/> : copy + atomic
|
||||||
|
/// rename + backups horodatés + revert manuel.
|
||||||
|
///
|
||||||
|
/// On réutilise les types <see cref="DeployResult"/>, <see cref="DeployStatus"/>,
|
||||||
|
/// <see cref="DeployProgress"/> et <see cref="BackupInfo"/> du namespace ReportTool
|
||||||
|
/// pour ne pas dupliquer la modélisation — le mécanisme est strictement le même.
|
||||||
|
/// </summary>
|
||||||
|
public interface IDocToolDeployer
|
||||||
|
{
|
||||||
|
Task<DeployResult> DeployAsync(
|
||||||
|
string installFolder,
|
||||||
|
IProgress<DeployProgress>? progress,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -1,41 +1,72 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
using Polly;
|
using Polly;
|
||||||
using Polly.Retry;
|
using Polly.Retry;
|
||||||
|
using PSLauncher.Core.Configuration;
|
||||||
using PSLauncher.Core.Integrity;
|
using PSLauncher.Core.Integrity;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.Core.Downloads;
|
namespace PSLauncher.Core.Downloads;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Téléchargement HTTP avec :
|
||||||
|
/// - reprise par Range requests (If-Range sur ETag/Last-Modified) ;
|
||||||
|
/// - retry exponentiel via Polly (1-32 s, 6 tentatives, jitter) ;
|
||||||
|
/// - téléchargement parallèle multi-segments (config <see cref="LocalConfig.ParallelDownloadSegments"/>) ;
|
||||||
|
/// - vérif SHA-256 streaming en fin de DL ;
|
||||||
|
/// - persistance d'état (state.json) toutes les 5 s ou 100 Mo pour reprise après crash.
|
||||||
|
///
|
||||||
|
/// Le multi-segments contourne le throttling per-connection d'Apache (notamment OVH mutualisé)
|
||||||
|
/// : chaque segment ouvre une connexion TCP séparée et écrit à son offset dans le fichier
|
||||||
|
/// pré-alloué via <c>SetLength</c>. Speed-up typique observé : ×4 à ×10.
|
||||||
|
/// </summary>
|
||||||
public sealed class DownloadManager : IDownloadManager
|
public sealed class DownloadManager : IDownloadManager
|
||||||
{
|
{
|
||||||
private const int BufferSize = 1 << 20; // 1 MiB
|
private const int BufferSize = 4 << 20; // 4 MiB par segment (avant 1 MiB)
|
||||||
private const int StateFlushBytesInterval = 100 * 1024 * 1024; // 100 MiB
|
private const int StateFlushBytesInterval = 50 * 1024 * 1024; // 50 MiB
|
||||||
private const double StateFlushSecondsInterval = 5.0;
|
private const double StateFlushSecondsInterval = 5.0;
|
||||||
|
private const long MultiSegmentMinSize = 50 * 1024 * 1024; // < 50 MiB → single-segment
|
||||||
|
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly IDownloadStateStore _stateStore;
|
private readonly IDownloadStateStore _stateStore;
|
||||||
private readonly IIntegrityService _integrity;
|
private readonly IIntegrityService _integrity;
|
||||||
|
private readonly LocalConfig _config;
|
||||||
private readonly ILogger<DownloadManager> _logger;
|
private readonly ILogger<DownloadManager> _logger;
|
||||||
private readonly ResiliencePipeline _retryPipeline;
|
private readonly ResiliencePipeline _retryPipeline;
|
||||||
|
|
||||||
|
// URL active partagée entre tous les segments d'un DL en cours, et lock pour
|
||||||
|
// refresher de façon coordonnée quand l'URL HMAC expire (TTL serveur = 6 h).
|
||||||
|
// Plusieurs segments peuvent recevoir 403 simultanément — un seul doit faire
|
||||||
|
// l'appel /api/download-url/.
|
||||||
|
private Uri _currentUrl = new("about:blank");
|
||||||
|
private DateTime _lastUrlRefresh = DateTime.MinValue;
|
||||||
|
private readonly SemaphoreSlim _urlRefreshLock = new(1, 1);
|
||||||
|
|
||||||
public DownloadManager(
|
public DownloadManager(
|
||||||
HttpClient http,
|
HttpClient http,
|
||||||
IDownloadStateStore stateStore,
|
IDownloadStateStore stateStore,
|
||||||
IIntegrityService integrity,
|
IIntegrityService integrity,
|
||||||
|
LocalConfig config,
|
||||||
ILogger<DownloadManager> logger)
|
ILogger<DownloadManager> logger)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_stateStore = stateStore;
|
_stateStore = stateStore;
|
||||||
_integrity = integrity;
|
_integrity = integrity;
|
||||||
|
_config = config;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
_retryPipeline = new ResiliencePipelineBuilder()
|
_retryPipeline = new ResiliencePipelineBuilder()
|
||||||
.AddRetry(new RetryStrategyOptions
|
.AddRetry(new RetryStrategyOptions
|
||||||
{
|
{
|
||||||
MaxRetryAttempts = 6,
|
// 10 tentatives × backoff 1→32s avec jitter ≈ ~3 min de retry budget
|
||||||
|
// par segment. Couvre les outages PHP-FPM courts en multi-PC (où des
|
||||||
|
// segments peuvent être tués mid-stream par request_terminate_timeout)
|
||||||
|
// sans donner l'impression d'une boucle infinie en cas de panne réelle.
|
||||||
|
MaxRetryAttempts = 10,
|
||||||
BackoffType = DelayBackoffType.Exponential,
|
BackoffType = DelayBackoffType.Exponential,
|
||||||
Delay = TimeSpan.FromSeconds(1),
|
Delay = TimeSpan.FromSeconds(1),
|
||||||
MaxDelay = TimeSpan.FromSeconds(32),
|
MaxDelay = TimeSpan.FromSeconds(32),
|
||||||
@@ -60,28 +91,565 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
|
|
||||||
public void DiscardResumableState(string version) => _stateStore.Discard(version);
|
public void DiscardResumableState(string version) => _stateStore.Discard(version);
|
||||||
|
|
||||||
public async Task<string> DownloadAsync(
|
public Task<string> DownloadAsync(
|
||||||
DownloadJob job,
|
DownloadJob job,
|
||||||
IProgress<DownloadProgress>? progress,
|
IProgress<DownloadProgress>? progress,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
|
=> DownloadAsync(job, progress, null, ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Récupère l'URL active. Si la dernière récup date de moins de 5 s,
|
||||||
|
/// retourne celle en cache (évite que 8 segments en 403 simultanés
|
||||||
|
/// déclenchent 8 requêtes /api/download-url/). Sinon refresh via le
|
||||||
|
/// callback du job. Si pas de callback, retourne l'URL initiale du job.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Uri> GetOrRefreshUrlAsync(DownloadJob job, bool forceRefresh, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await _urlRefreshLock.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Premier appel : initialise avec l'URL du job
|
||||||
|
if (_currentUrl.Scheme == "about")
|
||||||
|
{
|
||||||
|
_currentUrl = job.Url;
|
||||||
|
_lastUrlRefresh = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
// Si refresh forcé (un segment a vu 403), tente de fetcher une nouvelle URL.
|
||||||
|
// Debounce 5 s pour fusionner les refresh en rafale des 8 segments.
|
||||||
|
if (forceRefresh && (DateTime.UtcNow - _lastUrlRefresh).TotalSeconds >= 5
|
||||||
|
&& job.RefreshUrlAsync is not null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Refreshing signed URL for v{Version} (URL expired or 403)", job.Version);
|
||||||
|
var fresh = await job.RefreshUrlAsync(ct).ConfigureAwait(false);
|
||||||
|
if (fresh is not null)
|
||||||
|
{
|
||||||
|
_currentUrl = fresh;
|
||||||
|
_lastUrlRefresh = DateTime.UtcNow;
|
||||||
|
_logger.LogInformation("Signed URL refreshed for v{Version}", job.Version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _currentUrl;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_urlRefreshLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> DownloadAsync(
|
||||||
|
DownloadJob job,
|
||||||
|
IProgress<DownloadProgress>? progress,
|
||||||
|
IProgress<double>? hashProgress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Reset URL state pour ce job (DownloadManager est singleton DI)
|
||||||
|
_currentUrl = job.Url;
|
||||||
|
_lastUrlRefresh = DateTime.UtcNow;
|
||||||
var partialPath = _stateStore.GetPartialPath(job.Version);
|
var partialPath = _stateStore.GetPartialPath(job.Version);
|
||||||
var finalPath = Path.ChangeExtension(partialPath, null); // strip ".partial" → .zip
|
var finalPath = Path.ChangeExtension(partialPath, null); // strip ".partial" → .zip
|
||||||
|
|
||||||
// 1. Récupère l'état existant (s'il y en a un, et qu'il correspond à la même URL/sha)
|
// 1. Récupère l'état existant. On compare la ressource par son SHA-256 (signé via le
|
||||||
|
// manifest) plutôt que par URL : les URLs HMAC-signées changent à chaque appel
|
||||||
|
// (?exp=…&sig=…), donc une comparaison stricte d'URL invaliderait toujours le resume.
|
||||||
|
// On compare aussi le path (host+path sans query) pour détecter un changement de
|
||||||
|
// serveur/fichier, mais on ignore la querystring.
|
||||||
var existing = _stateStore.Load(job.Version);
|
var existing = _stateStore.Load(job.Version);
|
||||||
if (existing is not null && (existing.Url != job.Url.ToString() || existing.ExpectedSha256 != job.ExpectedSha256))
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Cached state for {Version} mismatches new job (url/sha changed) — discarding", job.Version);
|
bool shaMismatch = !string.IsNullOrEmpty(job.ExpectedSha256)
|
||||||
_stateStore.Discard(job.Version);
|
&& !string.IsNullOrEmpty(existing.ExpectedSha256)
|
||||||
existing = null;
|
&& !string.Equals(existing.ExpectedSha256, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase);
|
||||||
|
bool pathMismatch = false;
|
||||||
|
if (Uri.TryCreate(existing.Url, UriKind.Absolute, out var oldUri))
|
||||||
|
{
|
||||||
|
pathMismatch = oldUri.Host != job.Url.Host || oldUri.AbsolutePath != job.Url.AbsolutePath;
|
||||||
|
}
|
||||||
|
if (shaMismatch || pathMismatch)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Cached state for {Version} mismatches new job (sha={Sha} path={Path}) — discarding",
|
||||||
|
job.Version, shaMismatch, pathMismatch);
|
||||||
|
_stateStore.Discard(job.Version);
|
||||||
|
existing = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Même ressource : on rafraîchit juste l'URL stockée (utile pour les futures requêtes
|
||||||
|
// au sein de cette session, même si en pratique chaque segment requête via job.Url).
|
||||||
|
existing.Url = job.Url.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Choix single vs multi-segment
|
||||||
|
// Hard-cap à 32 : au-delà OVH mutualisé risque le rate-limit per-IP et le
|
||||||
|
// gain marginal devient négatif. 16 = défaut, 24-32 OK pour les serveurs
|
||||||
|
// qui le supportent. 1 = comportement single-segment legacy.
|
||||||
|
var requestedSegments = Math.Clamp(_config.ParallelDownloadSegments, 1, 32);
|
||||||
|
var useMultiSegment = requestedSegments > 1
|
||||||
|
&& job.ExpectedSize >= MultiSegmentMinSize
|
||||||
|
&& (existing is null || existing.Segments.Count > 0);
|
||||||
|
|
||||||
|
// Si l'état existant est en mode legacy (single-segment) et qu'on veut multi → on conserve le legacy
|
||||||
|
// pour pouvoir reprendre proprement (sinon on jetterait son DL).
|
||||||
|
if (existing is { Segments.Count: 0 } && File.Exists(partialPath))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Existing single-segment partial detected — staying single-segment for resume");
|
||||||
|
useMultiSegment = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Vérif espace disque (uniquement à l'initial, pas au resume)
|
||||||
|
if (existing is null && job.ExpectedSize > 0)
|
||||||
|
{
|
||||||
|
var drive = new DriveInfo(Path.GetPathRoot(_stateStore.GetDownloadsDirectory())!);
|
||||||
|
var needed = (long)(job.ExpectedSize * 1.05);
|
||||||
|
if (drive.AvailableFreeSpace < needed)
|
||||||
|
throw new IOException(
|
||||||
|
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useMultiSegment)
|
||||||
|
{
|
||||||
|
return await DownloadMultiSegmentAsync(
|
||||||
|
job, partialPath, finalPath, existing, requestedSegments, progress, hashProgress, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
return await DownloadSingleSegmentAsync(
|
||||||
|
job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ============== MULTI-SEGMENT (parallèle, fast path) ==============
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private async Task<string> DownloadMultiSegmentAsync(
|
||||||
|
DownloadJob job,
|
||||||
|
string partialPath,
|
||||||
|
string finalPath,
|
||||||
|
DownloadState? existing,
|
||||||
|
int segmentCount,
|
||||||
|
IProgress<DownloadProgress>? progress,
|
||||||
|
IProgress<double>? hashProgress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var swPhase = Stopwatch.StartNew();
|
||||||
|
// 1. On fait CONFIANCE au manifest : pas de HEAD probe.
|
||||||
|
// Le HEAD ajoutait jusqu'à 30 s-5 min de latence quand le serveur faisait
|
||||||
|
// un md5_file() sur 14 Go à chaque requête (vu sur OVH/gate.php). Le manifest
|
||||||
|
// est signé Ed25519 donc sa taille est de toute façon fiable. Si le serveur
|
||||||
|
// ne supporte pas Range, on le détecte sur le premier segment (200 au lieu
|
||||||
|
// de 206) et on fallback sur single-segment.
|
||||||
|
long total = job.ExpectedSize;
|
||||||
|
if (total <= 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("ExpectedSize=0 — falling back to single-segment");
|
||||||
|
return await DownloadSingleSegmentAsync(job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. State (resume vs fresh)
|
||||||
|
DownloadState state;
|
||||||
|
if (existing is not null && existing.Segments.Count > 0 && existing.TotalBytes == total && File.Exists(partialPath))
|
||||||
|
{
|
||||||
|
state = existing;
|
||||||
|
_logger.LogInformation("Resuming multi-segment DL for v{Version} ({Segments} segments, {Done}/{Total} bytes)",
|
||||||
|
job.Version, state.Segments.Count, state.DownloadedBytes, state.TotalBytes);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Discard tout résidu (state mismatch ou partial sans state)
|
||||||
|
_stateStore.Discard(job.Version);
|
||||||
|
state = new DownloadState
|
||||||
|
{
|
||||||
|
Version = job.Version,
|
||||||
|
Url = job.Url.ToString(),
|
||||||
|
TotalBytes = total,
|
||||||
|
ExpectedSha256 = job.ExpectedSha256,
|
||||||
|
PartialPath = partialPath,
|
||||||
|
StartedAtUtc = DateTime.UtcNow,
|
||||||
|
LastChunkAtUtc = DateTime.UtcNow,
|
||||||
|
Segments = BuildSegments(total, segmentCount),
|
||||||
|
};
|
||||||
|
_stateStore.Save(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Pré-allocation du fichier final.
|
||||||
|
// Stratégie : on tente de marquer le fichier en SPARSE (FSCTL_SET_SPARSE)
|
||||||
|
// avant le SetLength, ce qui rend l'opération métadata-only (~milliseconde
|
||||||
|
// au lieu de 5-30 s sur HDD à cause du zero-fill NTFS).
|
||||||
|
// Pas besoin d'admin (contrairement à SetFileValidData). Aucune fuite de
|
||||||
|
// données : les zones non écrites sont lues comme zéros virtuels par NTFS.
|
||||||
|
// Si FSCTL_SET_SPARSE échoue (volume FAT32 ou autre), fallback sur SetLength
|
||||||
|
// classique.
|
||||||
|
var swAlloc = Stopwatch.StartNew();
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
using var alloc = new FileStream(partialPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite);
|
||||||
|
if (alloc.Length != total)
|
||||||
|
{
|
||||||
|
bool sparseOk = SparseFileSupport.TrySetSparse(alloc.SafeFileHandle);
|
||||||
|
_logger.LogInformation("Pre-allocating {Path} to {Bytes} bytes (sparse={Sparse})", partialPath, total, sparseOk);
|
||||||
|
alloc.SetLength(total);
|
||||||
|
}
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
swAlloc.Stop();
|
||||||
|
_logger.LogInformation("Pre-alloc done in {Ms} ms (HEAD skipped, total phase {TotalMs} ms)", swAlloc.ElapsedMilliseconds, swPhase.ElapsedMilliseconds);
|
||||||
|
|
||||||
|
// Une fois la pré-alloc terminée, on émet immédiatement un progress 0/total
|
||||||
|
// pour que le footer passe de « Préparation… » à « ⬇ v1.4.6 : 0 / 14 Go ».
|
||||||
|
// La vraie progression suit ~250 ms après quand le reporter task tick.
|
||||||
|
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, null));
|
||||||
|
|
||||||
|
// 4. Lance les workers en parallèle.
|
||||||
|
// Compteur agrégé via Interlocked → pas de lock dans le hot path.
|
||||||
|
// Initialisé sur la somme des seg.DownloadedBytes DURABLES plutôt que sur
|
||||||
|
// state.DownloadedBytes (live aggregate persisté, qui peut être en avance
|
||||||
|
// de la réalité disque si pause survenue entre checkpoints). Sans ça, le
|
||||||
|
// footer afficherait "paused at X" mais segments resumeraient à un point
|
||||||
|
// en arrière, et l'aggregate finirait avec un drift cosmétique.
|
||||||
|
long aggregateBytes = state.Segments.Sum(s => s.DownloadedBytes);
|
||||||
|
|
||||||
|
// Action passée aux segments : juste un Add atomique (zéro contention).
|
||||||
|
void OnSegmentBytes(int _, long deltaBytes)
|
||||||
|
=> Interlocked.Add(ref aggregateBytes, deltaBytes);
|
||||||
|
|
||||||
|
// Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte.
|
||||||
|
// Tous les 5 s ou 50 Mo, persist le state.json.
|
||||||
|
// Tourne sur un thread séparé pour ne jamais être starvé par les workers DL.
|
||||||
|
//
|
||||||
|
// Calcul du débit : on utilise une FENÊTRE GLISSANTE de ~3 s (12 samples
|
||||||
|
// de 250ms) plutôt qu'un delta instantané sur le dernier tick. Pourquoi :
|
||||||
|
// avec un delta tick-à-tick, dès qu'un segment finit OU que Polly retry
|
||||||
|
// entre 2 tentatives OU qu'il y a un micro-stall réseau, le compteur ne
|
||||||
|
// bouge pas pendant 1-2 ticks → bps = 0 → ETA = null → UI cache les
|
||||||
|
// deux 250-500 ms → clignotement permanent à l'écran (symptôme rapporté).
|
||||||
|
// Avec la fenêtre, un trou de 1-2 ticks est dilué dans 12 samples → le
|
||||||
|
// débit affiché reste stable. La latence d'adaptation est ~3s, ce qui
|
||||||
|
// est largement OK visuellement (l'opérateur ne perçoit pas 3s de retard
|
||||||
|
// sur un chiffre qui de toute façon fluctue de ±5-10 % au cours d'un DL).
|
||||||
|
const int BpsWindowSize = 12; // 12 × 250 ms = 3 s
|
||||||
|
using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
var reporterTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var lastFlush = TimeSpan.Zero;
|
||||||
|
long lastFlushBytes = Interlocked.Read(ref aggregateBytes);
|
||||||
|
// Fenêtre glissante (time, bytes) pour lissage du débit.
|
||||||
|
var bpsWindow = new Queue<(TimeSpan At, long Bytes)>(BpsWindowSize + 1);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!reporterCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
|
||||||
|
var snapshot = Interlocked.Read(ref aggregateBytes);
|
||||||
|
var elapsed = sw.Elapsed;
|
||||||
|
|
||||||
|
// Push le nouveau sample, évince le plus ancien si on dépasse.
|
||||||
|
bpsWindow.Enqueue((elapsed, snapshot));
|
||||||
|
while (bpsWindow.Count > BpsWindowSize) bpsWindow.Dequeue();
|
||||||
|
|
||||||
|
// bps = (bytes_now - bytes_oldest_in_window) / time_span_window
|
||||||
|
// Fallback à 0 tant qu'on n'a pas au moins 2 samples (le 1er tick).
|
||||||
|
double bps = 0;
|
||||||
|
if (bpsWindow.Count >= 2)
|
||||||
|
{
|
||||||
|
var oldest = bpsWindow.Peek();
|
||||||
|
var span = (elapsed - oldest.At).TotalSeconds;
|
||||||
|
if (span > 0) bps = (snapshot - oldest.Bytes) / span;
|
||||||
|
}
|
||||||
|
TimeSpan? eta = null;
|
||||||
|
if (bps > 0 && total > snapshot)
|
||||||
|
eta = TimeSpan.FromSeconds((total - snapshot) / bps);
|
||||||
|
progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
|
||||||
|
|
||||||
|
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
|
||||||
|
snapshot - lastFlushBytes >= StateFlushBytesInterval)
|
||||||
|
{
|
||||||
|
state.DownloadedBytes = snapshot;
|
||||||
|
state.LastChunkAtUtc = DateTime.UtcNow;
|
||||||
|
_stateStore.Save(state);
|
||||||
|
lastFlush = elapsed;
|
||||||
|
lastFlushBytes = snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* attendu en fin de DL */ }
|
||||||
|
}, reporterCts.Token);
|
||||||
|
|
||||||
|
var tasks = state.Segments
|
||||||
|
.Where(s => !s.Completed)
|
||||||
|
.Select(s => Task.Run(() =>
|
||||||
|
_retryPipeline.ExecuteAsync(async innerCt =>
|
||||||
|
{
|
||||||
|
await DownloadSegmentAsync(job, state, s, partialPath, OnSegmentBytes, innerCt).ConfigureAwait(false);
|
||||||
|
}, ct).AsTask(), ct))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
reporterCts.Cancel();
|
||||||
|
try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ }
|
||||||
|
state.DownloadedBytes = Interlocked.Read(ref aggregateBytes);
|
||||||
|
_stateStore.Save(state);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stoppe le reporter et fais un dernier flush
|
||||||
|
reporterCts.Cancel();
|
||||||
|
try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ }
|
||||||
|
state.DownloadedBytes = Interlocked.Read(ref aggregateBytes);
|
||||||
|
_stateStore.Save(state);
|
||||||
|
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, TimeSpan.Zero));
|
||||||
|
|
||||||
|
// 5. Vérif complétion des segments
|
||||||
|
// Le check `actualSize != total` ci-dessous est INSUFFISANT car le fichier est
|
||||||
|
// pré-alloué en sparse à `total` octets — sa taille filesystem matchera toujours
|
||||||
|
// peu importe ce qui a été écrit. Si un segment a échoué silencieusement (cas
|
||||||
|
// pré-0.24.5 ou bug futur), on aurait des trous (zéros NTFS) → SHA KO en sortie
|
||||||
|
// sans diagnostic clair. On vérifie explicitement que CHAQUE segment a atteint
|
||||||
|
// sa cible AVANT de lancer le SHA-256 (qui prend 30s-3min sur un 14 Go).
|
||||||
|
var incomplete = state.Segments.FirstOrDefault(s => !s.Completed || s.DownloadedBytes < s.Length);
|
||||||
|
if (incomplete is not null)
|
||||||
|
{
|
||||||
|
_stateStore.Save(state); // garde l'état pour permettre un resume manuel
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"Segment {incomplete.Index} incomplet : {incomplete.DownloadedBytes:N0}/{incomplete.Length:N0} octets " +
|
||||||
|
$"(Completed={incomplete.Completed}). Relance le téléchargement pour reprendre.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var actualSize = new FileInfo(partialPath).Length;
|
||||||
|
if (actualSize != total)
|
||||||
|
{
|
||||||
|
_stateStore.Discard(job.Version);
|
||||||
|
throw new InvalidDataException($"Taille téléchargée incorrecte : attendu {total}, obtenu {actualSize}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Vérif SHA-256
|
||||||
|
await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false);
|
||||||
|
return finalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<DownloadSegment> BuildSegments(long total, int count)
|
||||||
|
{
|
||||||
|
var segments = new List<DownloadSegment>(count);
|
||||||
|
long segSize = total / count;
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
long start = i * segSize;
|
||||||
|
long end = (i == count - 1) ? total - 1 : (start + segSize - 1);
|
||||||
|
segments.Add(new DownloadSegment
|
||||||
|
{
|
||||||
|
Index = i,
|
||||||
|
Start = start,
|
||||||
|
End = end,
|
||||||
|
DownloadedBytes = 0,
|
||||||
|
Completed = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadSegmentAsync(
|
||||||
|
DownloadJob job,
|
||||||
|
DownloadState state,
|
||||||
|
DownloadSegment seg,
|
||||||
|
string partialPath,
|
||||||
|
Action<int, long> onBytes,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (seg.Completed) return;
|
||||||
|
|
||||||
|
var segStart = seg.Start + seg.DownloadedBytes;
|
||||||
|
if (segStart > seg.End) { seg.Completed = true; return; }
|
||||||
|
|
||||||
|
// Récupère l'URL active (peut avoir été refreshée par un autre segment)
|
||||||
|
var url = await GetOrRefreshUrlAsync(job, forceRefresh: false, ct).ConfigureAwait(false);
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
req.Headers.Range = new RangeHeaderValue(segStart, seg.End);
|
||||||
|
if (!string.IsNullOrEmpty(state.Etag))
|
||||||
|
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
|
||||||
|
|
||||||
|
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (resp.StatusCode == HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
// Le serveur a renvoyé tout au lieu du range : la ressource a probablement changé
|
||||||
|
// (ou il n'a en fait pas accepté le range). On signale un retry à blanc.
|
||||||
|
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 403 / 410 / 404 = URL HMAC expirée (gate.php renvoie 403 "Forbidden:
|
||||||
|
// expired"), OU le fichier a été renommé/supprimé côté serveur (cas
|
||||||
|
// typique : ré-upload du ZIP avec un nouveau nom pour invalider le cache
|
||||||
|
// OVH CDN). Dans les deux cas, refresh l'URL via le callback du job —
|
||||||
|
// l'endpoint /download-url/{version} renvoie la NOUVELLE URL pointant
|
||||||
|
// vers le fichier actuellement présent sur le serveur. Si refresh donne
|
||||||
|
// une URL DIFFÉRENTE de celle qu'on a utilisée dans la requête → retry
|
||||||
|
// avec la nouvelle. Sinon → c'est un vrai 404, on bubble.
|
||||||
|
//
|
||||||
|
// Note importante : on compare à `url` (la valeur utilisée dans la
|
||||||
|
// requête HTTP, capturée ligne 438 avant le send) plutôt qu'à un
|
||||||
|
// snapshot pris juste avant forceRefresh. Pourquoi : si 8 segments
|
||||||
|
// tombent en 404 simultanément, le PREMIER refresh met _currentUrl à
|
||||||
|
// jour, les autres voient le debounce 5s (no-op). Comparer à `url`
|
||||||
|
// (= URL réellement utilisée par CETTE requête, OLD) détecte
|
||||||
|
// correctement que _currentUrl est maintenant NEW → retry. Si on
|
||||||
|
// comparait à un snapshot pré-refresh, on raterait ce cas et on jetterait
|
||||||
|
// un faux 404 fatal sur tous les segments sauf le premier.
|
||||||
|
bool isUrlRefreshTrigger = resp.StatusCode == HttpStatusCode.Forbidden
|
||||||
|
|| resp.StatusCode == HttpStatusCode.Gone
|
||||||
|
|| resp.StatusCode == HttpStatusCode.NotFound
|
||||||
|
|| resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable;
|
||||||
|
if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null)
|
||||||
|
{
|
||||||
|
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
|
||||||
|
bool urlChanged = !Uri.Equals(url, _currentUrl);
|
||||||
|
if (urlChanged)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Got HTTP {Status} on segment {Seg}, URL refreshed (old={Old} → new={New}), retrying",
|
||||||
|
(int)resp.StatusCode, seg.Index, url, _currentUrl);
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"HTTP {(int)resp.StatusCode} on segment {seg.Index}, URL refreshed",
|
||||||
|
isTransient: true);
|
||||||
|
}
|
||||||
|
// URL unchanged → soit le serveur insiste sur la même URL morte,
|
||||||
|
// soit le refresh a fallback sur le manifest cached (donc stale).
|
||||||
|
// 404 non-transient pour arrêter Polly et remonter une erreur claire
|
||||||
|
// qui guide l'opérateur vers « Vérifier les MAJ ».
|
||||||
|
if (resp.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"HTTP 404 on segment {seg.Index} — le fichier a été renommé/supprimé côté serveur et le manifest local est obsolète. Clique « Vérifier les MAJ » avant de réessayer.",
|
||||||
|
isTransient: false);
|
||||||
|
}
|
||||||
|
// 416 unchanged URL = vrai mismatch de taille. On fait un HEAD probe
|
||||||
|
// pour obtenir la taille RÉELLE côté serveur et la rapporter dans le
|
||||||
|
// message d'erreur — l'opérateur saura ainsi s'il a un upload SFTP
|
||||||
|
// tronqué (file plus petit que prévu) ou un manifest qui ment sur
|
||||||
|
// sizeBytes. Cas concret : SFTP coupe au milieu d'un upload de 13 Go
|
||||||
|
// → le file fait 7 Go sur disque, mais le manifest dit 13 Go.
|
||||||
|
// Segment N qui demande bytes 9G-10G reçoit 416 (out of range).
|
||||||
|
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
|
||||||
|
{
|
||||||
|
long? actualSize = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var headReq = new HttpRequestMessage(HttpMethod.Head, url);
|
||||||
|
using var headResp = await _http.SendAsync(headReq, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
||||||
|
if (headResp.IsSuccessStatusCode)
|
||||||
|
actualSize = headResp.Content.Headers.ContentLength;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _logger.LogDebug(ex, "HEAD probe after 416 failed"); }
|
||||||
|
|
||||||
|
var actualStr = actualSize.HasValue ? $"{actualSize.Value:N0}" : "?";
|
||||||
|
var expectedStr = $"{state.TotalBytes:N0}";
|
||||||
|
_logger.LogError(
|
||||||
|
"HTTP 416 on segment {Seg} — actual server file size: {Actual} bytes, manifest expects: {Expected} bytes",
|
||||||
|
seg.Index, actualStr, expectedStr);
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"HTTP 416 on segment {seg.Index} — incohérence taille de fichier serveur : " +
|
||||||
|
$"le ZIP sur le serveur fait {actualStr} octets, le manifest attend {expectedStr} octets. " +
|
||||||
|
$"Probable cause : upload SFTP tronqué OU manifest avec sizeBytes/sha256 obsolètes. " +
|
||||||
|
$"Re-upload le ZIP complet puis « 🔁 Hasher les versions + signer » côté admin.",
|
||||||
|
isTransient: false);
|
||||||
|
}
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"Signed URL expired (HTTP {(int)resp.StatusCode}), refreshed for next try", isTransient: true);
|
||||||
|
}
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
|
||||||
|
throw new HttpResumableException($"HTTP {(int)resp.StatusCode} on segment {seg.Index}", isTransient: transient);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
|
||||||
|
if (resp.Content.Headers.LastModified is { } lm) state.LastModified = lm.ToString("R");
|
||||||
|
|
||||||
|
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||||
|
await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true);
|
||||||
|
dst.Seek(segStart, SeekOrigin.Begin);
|
||||||
|
|
||||||
|
// CHECKPOINT : durabilité ET anti-double-comptage.
|
||||||
|
//
|
||||||
|
// Durabilité : on n'incrémente seg.DownloadedBytes qu'APRÈS un FlushAsync
|
||||||
|
// réussi. Sans ça, sur pause/cancel, le buffer FileStream (4 MiB) ou le
|
||||||
|
// cache OS peuvent ne pas avoir atteint le disque, mais seg.DownloadedBytes
|
||||||
|
// les compte comme écrits → resume saute ces bytes → trou (zéros NTFS du
|
||||||
|
// pre-alloc sparse) → SHA-256 fail.
|
||||||
|
//
|
||||||
|
// Anti-double-comptage : on appelle ÉGALEMENT `onBytes` uniquement au
|
||||||
|
// checkpoint, avec le delta inFlight. Sans ça, un retry Polly mid-segment
|
||||||
|
// (très fréquent sur PHP-FPM OVH mutualisé qui coupe les requêtes longues)
|
||||||
|
// re-télécharge les bytes depuis le dernier checkpoint et les compte une
|
||||||
|
// 2e fois dans l'aggregate → footer affiche >100% en fin de DL.
|
||||||
|
// Avec onBytes au checkpoint : les bytes re-téléchargés n'ont jamais été
|
||||||
|
// reportés à la 1re tentative (l'erreur Polly est levée AVANT le checkpoint
|
||||||
|
// sur l'attempt failed), donc pas de double count.
|
||||||
|
//
|
||||||
|
// Trade-off : UI/footer update tous les 64 MiB par segment au lieu de
|
||||||
|
// chaque 4 MiB. Avec 16 segments en parallèle, ça reste ~10-20 updates/s
|
||||||
|
// au pic, le reporter task échantillonne à 4 Hz donc invisible côté UX.
|
||||||
|
const long FlushIntervalBytes = 64L * 1024 * 1024;
|
||||||
|
var buffer = new byte[BufferSize];
|
||||||
|
long inFlight = 0;
|
||||||
|
int n;
|
||||||
|
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
|
||||||
|
{
|
||||||
|
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
||||||
|
inFlight += n;
|
||||||
|
|
||||||
|
if (inFlight >= FlushIntervalBytes)
|
||||||
|
{
|
||||||
|
await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
seg.DownloadedBytes += inFlight;
|
||||||
|
onBytes(seg.Index, inFlight);
|
||||||
|
inFlight = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Checkpoint final : flush + ack du reste du buffer.
|
||||||
|
await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
if (inFlight > 0)
|
||||||
|
{
|
||||||
|
seg.DownloadedBytes += inFlight;
|
||||||
|
onBytes(seg.Index, inFlight);
|
||||||
|
inFlight = 0;
|
||||||
|
}
|
||||||
|
seg.Completed = (seg.DownloadedBytes >= seg.Length);
|
||||||
|
|
||||||
|
// Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a
|
||||||
|
// fermé la connexion sans erreur HTTP (typique : PHP-FPM hit `request_terminate_timeout`
|
||||||
|
// sur un gros segment, OVH coupe la connexion silencieusement, proxy intermédiaire qui
|
||||||
|
// ferme). Sans cette détection, le segment retourne `Completed=false` mais sans exception,
|
||||||
|
// Polly ne retry pas, le fichier final a des trous (zones sparse jamais écrites = zéros
|
||||||
|
// NTFS) et le SHA-256 échoue à la fin. On lance une exception transitoire pour forcer
|
||||||
|
// un retry — celui-ci reprendra à l'offset où on s'est arrêté grâce à seg.DownloadedBytes.
|
||||||
|
if (!seg.Completed)
|
||||||
|
{
|
||||||
|
var missing = seg.Length - seg.DownloadedBytes;
|
||||||
|
throw new HttpResumableException(
|
||||||
|
$"Segment {seg.Index} : connexion fermée prématurément, {missing:N0} octets manquants (probablement timeout PHP-FPM serveur)",
|
||||||
|
isTransient: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ============= SINGLE-SEGMENT (legacy / fallback path) ============
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private async Task<string> DownloadSingleSegmentAsync(
|
||||||
|
DownloadJob job,
|
||||||
|
string partialPath,
|
||||||
|
string finalPath,
|
||||||
|
DownloadState? existing,
|
||||||
|
IProgress<DownloadProgress>? progress,
|
||||||
|
IProgress<double>? hashProgress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
long resumeFrom = 0;
|
long resumeFrom = 0;
|
||||||
if (existing is not null && File.Exists(partialPath))
|
if (existing is not null && File.Exists(partialPath))
|
||||||
{
|
{
|
||||||
resumeFrom = new FileInfo(partialPath).Length;
|
resumeFrom = new FileInfo(partialPath).Length;
|
||||||
// Si la state.json est en avance par rapport au .partial réel, on s'aligne sur le .partial
|
|
||||||
if (resumeFrom != existing.DownloadedBytes)
|
if (resumeFrom != existing.DownloadedBytes)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
@@ -92,21 +660,10 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
}
|
}
|
||||||
else if (File.Exists(partialPath))
|
else if (File.Exists(partialPath))
|
||||||
{
|
{
|
||||||
// Fichier partiel sans state : on repart à 0 par sécurité
|
|
||||||
File.Delete(partialPath);
|
File.Delete(partialPath);
|
||||||
resumeFrom = 0;
|
resumeFrom = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Vérif espace disque (uniquement à l'initial, pas au resume)
|
|
||||||
if (resumeFrom == 0 && job.ExpectedSize > 0)
|
|
||||||
{
|
|
||||||
var drive = new DriveInfo(Path.GetPathRoot(_stateStore.GetDownloadsDirectory())!);
|
|
||||||
var needed = (long)(job.ExpectedSize * 1.05);
|
|
||||||
if (drive.AvailableFreeSpace < needed)
|
|
||||||
throw new IOException(
|
|
||||||
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var state = existing ?? new DownloadState
|
var state = existing ?? new DownloadState
|
||||||
{
|
{
|
||||||
Version = job.Version,
|
Version = job.Version,
|
||||||
@@ -119,13 +676,11 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
LastChunkAtUtc = DateTime.UtcNow,
|
LastChunkAtUtc = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Tentatives avec retry — chaque tentative continue depuis la taille actuelle du .partial
|
|
||||||
await _retryPipeline.ExecuteAsync(async innerCt =>
|
await _retryPipeline.ExecuteAsync(async innerCt =>
|
||||||
{
|
{
|
||||||
await DownloadChunkAsync(job, state, partialPath, progress, innerCt).ConfigureAwait(false);
|
await DownloadChunkAsync(job, state, partialPath, progress, innerCt).ConfigureAwait(false);
|
||||||
}, ct).ConfigureAwait(false);
|
}, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
// 4. Vérif taille finale
|
|
||||||
var actualSize = new FileInfo(partialPath).Length;
|
var actualSize = new FileInfo(partialPath).Length;
|
||||||
if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
|
if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
|
||||||
{
|
{
|
||||||
@@ -134,27 +689,7 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
|
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Vérif SHA-256
|
await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false);
|
||||||
if (!string.IsNullOrEmpty(job.ExpectedSha256) &&
|
|
||||||
!job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath);
|
|
||||||
var sha = await _integrity.ComputeSha256Async(partialPath, null, ct).ConfigureAwait(false);
|
|
||||||
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
_stateStore.Discard(job.Version);
|
|
||||||
throw new InvalidDataException(
|
|
||||||
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (File.Exists(finalPath)) File.Delete(finalPath);
|
|
||||||
File.Move(partialPath, finalPath);
|
|
||||||
_stateStore.Discard(job.Version); // efface aussi le state.json puisqu'on a fini
|
|
||||||
return finalPath;
|
return finalPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,22 +701,17 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var resumeFrom = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0;
|
var resumeFrom = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0;
|
||||||
if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return; // déjà tout là
|
if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return;
|
||||||
|
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
|
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
|
||||||
if (resumeFrom > 0)
|
if (resumeFrom > 0)
|
||||||
{
|
{
|
||||||
req.Headers.Range = new RangeHeaderValue(resumeFrom, null);
|
req.Headers.Range = new RangeHeaderValue(resumeFrom, null);
|
||||||
// If-Range : si l'ETag du serveur a changé entre temps, on veut recommencer proprement
|
|
||||||
if (!string.IsNullOrEmpty(state.Etag))
|
if (!string.IsNullOrEmpty(state.Etag))
|
||||||
{
|
|
||||||
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
|
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
|
||||||
}
|
|
||||||
else if (!string.IsNullOrEmpty(state.LastModified) &&
|
else if (!string.IsNullOrEmpty(state.LastModified) &&
|
||||||
DateTimeOffset.TryParse(state.LastModified, out var lm))
|
DateTimeOffset.TryParse(state.LastModified, out var lm))
|
||||||
{
|
|
||||||
req.Headers.IfRange = new RangeConditionHeaderValue(lm);
|
req.Headers.IfRange = new RangeConditionHeaderValue(lm);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("HTTP GET {Url} range={Resume}-", job.Url, resumeFrom);
|
_logger.LogInformation("HTTP GET {Url} range={Resume}-", job.Url, resumeFrom);
|
||||||
@@ -189,16 +719,13 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
|
|
||||||
if (resumeFrom > 0 && resp.StatusCode == HttpStatusCode.OK)
|
if (resumeFrom > 0 && resp.StatusCode == HttpStatusCode.OK)
|
||||||
{
|
{
|
||||||
// Le serveur a renvoyé 200 alors qu'on demandait du Range : la ressource a changé.
|
|
||||||
_logger.LogWarning("Server returned 200 for ranged request — resource changed, restarting fresh");
|
_logger.LogWarning("Server returned 200 for ranged request — resource changed, restarting fresh");
|
||||||
resp.Dispose();
|
resp.Dispose();
|
||||||
File.Delete(partialPath);
|
File.Delete(partialPath);
|
||||||
_stateStore.Discard(job.Version);
|
_stateStore.Discard(job.Version);
|
||||||
// On retry sera traité par Polly via une exception
|
|
||||||
throw new HttpResumableException("Resource changed on server, restart", isTransient: true);
|
throw new HttpResumableException("Resource changed on server, restart", isTransient: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5xx / 408 / 429 → exception transitoire pour Polly
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
|
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
|
||||||
@@ -207,11 +734,9 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
isTransient: transient);
|
isTransient: transient);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture ETag et Last-Modified du serveur si présents (pour les futures reprises)
|
|
||||||
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
|
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
|
||||||
if (resp.Content.Headers.LastModified is { } lm2) state.LastModified = lm2.ToString("R");
|
if (resp.Content.Headers.LastModified is { } lm2) state.LastModified = lm2.ToString("R");
|
||||||
|
|
||||||
// Total : si le serveur ne fournit pas Content-Length, on garde la valeur du manifest
|
|
||||||
var contentLength = resp.Content.Headers.ContentLength ?? 0;
|
var contentLength = resp.Content.Headers.ContentLength ?? 0;
|
||||||
var total = state.TotalBytes;
|
var total = state.TotalBytes;
|
||||||
if (resp.StatusCode == HttpStatusCode.PartialContent && resp.Content.Headers.ContentRange is { Length: { } cl })
|
if (resp.StatusCode == HttpStatusCode.PartialContent && resp.Content.Headers.ContentRange is { Length: { } cl })
|
||||||
@@ -220,8 +745,6 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
total = contentLength;
|
total = contentLength;
|
||||||
if (total > 0) state.TotalBytes = total;
|
if (total > 0) state.TotalBytes = total;
|
||||||
|
|
||||||
// Si on est sur un 200 alors qu'on partait de 0, on écrit en append (FileMode.Append depuis position 0)
|
|
||||||
// Si on est sur un 206 avec resumeFrom>0, on append.
|
|
||||||
var mode = resumeFrom > 0 ? FileMode.Append : FileMode.Create;
|
var mode = resumeFrom > 0 ? FileMode.Append : FileMode.Create;
|
||||||
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||||
await using var dst = new FileStream(partialPath, mode, FileAccess.Write, FileShare.None, BufferSize, useAsync: true);
|
await using var dst = new FileStream(partialPath, mode, FileAccess.Write, FileShare.None, BufferSize, useAsync: true);
|
||||||
@@ -244,7 +767,6 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
|
|
||||||
var elapsed = sw.Elapsed;
|
var elapsed = sw.Elapsed;
|
||||||
|
|
||||||
// Progression UI
|
|
||||||
if ((elapsed - lastReport).TotalMilliseconds >= 250)
|
if ((elapsed - lastReport).TotalMilliseconds >= 250)
|
||||||
{
|
{
|
||||||
var deltaBytes = read - lastReportBytes;
|
var deltaBytes = read - lastReportBytes;
|
||||||
@@ -257,7 +779,6 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
lastReportBytes = read;
|
lastReportBytes = read;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist state.json toutes les 5s ou tous les 100 Mo
|
|
||||||
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
|
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
|
||||||
read - lastFlushBytes >= StateFlushBytesInterval)
|
read - lastFlushBytes >= StateFlushBytesInterval)
|
||||||
{
|
{
|
||||||
@@ -272,6 +793,59 @@ public sealed class DownloadManager : IDownloadManager
|
|||||||
_stateStore.Save(state);
|
_stateStore.Save(state);
|
||||||
progress?.Report(new DownloadProgress(read, state.TotalBytes, 0, TimeSpan.Zero));
|
progress?.Report(new DownloadProgress(read, state.TotalBytes, 0, TimeSpan.Zero));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ====================== FINALISATION COMMUNE ======================
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
private async Task VerifyAndFinalizeAsync(
|
||||||
|
DownloadJob job,
|
||||||
|
string partialPath,
|
||||||
|
string finalPath,
|
||||||
|
IProgress<double>? hashProgress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Évalue si on doit vérifier le hash :
|
||||||
|
// 1. Le manifest doit fournir un sha256 valide (pas vide, pas "REPLACE…")
|
||||||
|
// 2. L'algo demandé n'est pas "none"
|
||||||
|
// 3. La config locale n'a pas désactivé la vérification globale
|
||||||
|
bool hashAlgoSkipped = string.Equals(job.HashAlgorithm, "none", StringComparison.OrdinalIgnoreCase);
|
||||||
|
bool hasUsableSha = !string.IsNullOrEmpty(job.ExpectedSha256)
|
||||||
|
&& !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (hasUsableSha && !hashAlgoSkipped && _config.VerifyDownloadHash)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath);
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var sha = await _integrity.ComputeSha256Async(partialPath, hashProgress, ct).ConfigureAwait(false);
|
||||||
|
sw.Stop();
|
||||||
|
_logger.LogInformation("SHA-256 verified in {Elapsed} ({MBps:0.#} MB/s)",
|
||||||
|
sw.Elapsed,
|
||||||
|
sw.Elapsed.TotalSeconds > 0 ? new FileInfo(partialPath).Length / sw.Elapsed.TotalSeconds / (1024.0 * 1024.0) : 0);
|
||||||
|
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_stateStore.Discard(job.Version);
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (hashAlgoSkipped)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Hash verification skipped (manifest specifies hashAlgorithm=none)");
|
||||||
|
}
|
||||||
|
else if (!_config.VerifyDownloadHash)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Hash verification skipped (VerifyDownloadHash=false in local config)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(finalPath)) File.Delete(finalPath);
|
||||||
|
File.Move(partialPath, finalPath);
|
||||||
|
_stateStore.Discard(job.Version);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Exception interne pour signaler à Polly qu'on doit retry.</summary>
|
/// <summary>Exception interne pour signaler à Polly qu'on doit retry.</summary>
|
||||||
@@ -283,3 +857,42 @@ internal sealed class HttpResumableException : Exception
|
|||||||
IsTransient = isTransient;
|
IsTransient = isTransient;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static class SparseFileSupport
|
||||||
|
{
|
||||||
|
private const uint FSCTL_SET_SPARSE = 0x000900C4;
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool DeviceIoControl(
|
||||||
|
SafeFileHandle hDevice,
|
||||||
|
uint dwIoControlCode,
|
||||||
|
IntPtr lpInBuffer,
|
||||||
|
uint nInBufferSize,
|
||||||
|
IntPtr lpOutBuffer,
|
||||||
|
uint nOutBufferSize,
|
||||||
|
out uint lpBytesReturned,
|
||||||
|
IntPtr lpOverlapped);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marque le fichier comme sparse via FSCTL_SET_SPARSE.
|
||||||
|
/// Une fois sparse, <c>SetLength(14 GB)</c> est métadata-only (< 100 ms partout).
|
||||||
|
/// Les zones non écrites sont lues comme zéros virtuels (pas de fuite de données
|
||||||
|
/// disque, contrairement à SetFileValidData qui exigerait SeManageVolumePrivilege).
|
||||||
|
/// Retourne false si le volume ne supporte pas sparse (ex. FAT32) — auquel cas
|
||||||
|
/// on retombe sur le SetLength classique avec zero-fill.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TrySetSparse(SafeFileHandle handle)
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return DeviceIoControl(handle, FSCTL_SET_SPARSE,
|
||||||
|
IntPtr.Zero, 0, IntPtr.Zero, 0, out _, IntPtr.Zero);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ public interface IDownloadManager
|
|||||||
IProgress<DownloadProgress>? progress,
|
IProgress<DownloadProgress>? progress,
|
||||||
CancellationToken ct);
|
CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Variante avec callback de progression dédié pour la phase de vérification SHA-256.
|
||||||
|
/// La phase verify est CPU/IO-bound et peut prendre plusieurs minutes sur un build 14 Go ;
|
||||||
|
/// la UI doit pouvoir l'afficher distinctement du DL.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> DownloadAsync(
|
||||||
|
DownloadJob job,
|
||||||
|
IProgress<DownloadProgress>? progress,
|
||||||
|
IProgress<double>? hashProgress,
|
||||||
|
CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>Retourne l'état de DL persistant pour cette version, ou null s'il n'y en a pas.</summary>
|
/// <summary>Retourne l'état de DL persistant pour cette version, ou null s'il n'y en a pas.</summary>
|
||||||
DownloadState? GetResumableState(string version);
|
DownloadState? GetResumableState(string version);
|
||||||
|
|
||||||
@@ -20,4 +31,20 @@ public sealed record DownloadJob(
|
|||||||
string Version,
|
string Version,
|
||||||
Uri Url,
|
Uri Url,
|
||||||
long ExpectedSize,
|
long ExpectedSize,
|
||||||
string ExpectedSha256);
|
string ExpectedSha256)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Algo de vérif d'intégrité — "sha256" (défaut) ou "none" pour sauter la vérif.
|
||||||
|
/// Pris depuis <see cref="PSLauncher.Models.DownloadDescriptor.HashAlgorithm"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string? HashAlgorithm { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback pour obtenir une URL HMAC-signée fraîche en cas d'expiration mid-DL
|
||||||
|
/// (le DL d'un 14 Go sur connexion lente peut dépasser le TTL serveur de 6 h).
|
||||||
|
/// Le DownloadManager appelle ce callback quand un segment reçoit un 403/410
|
||||||
|
/// (indices d'URL expirée), récupère une URL fraîche, et reprend transparemment
|
||||||
|
/// le DL sans l'interrompre. Si null, pas d'auto-refresh : le DL fail proprement.
|
||||||
|
/// </summary>
|
||||||
|
public Func<CancellationToken, Task<Uri?>>? RefreshUrlAsync { get; init; }
|
||||||
|
}
|
||||||
|
|||||||
32
src/PSLauncher.Core/Health/ISystemHealthService.cs
Normal file
32
src/PSLauncher.Core/Health/ISystemHealthService.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Effectue les vérifications de santé système configurées dans
|
||||||
|
/// <see cref="HealthChecksConfig"/> : ping IP, processus tournant, etc.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISystemHealthService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exécute un check unique en async (ping ou check de process). Le résultat
|
||||||
|
/// porte la sévérité + un détail textuel destiné au tooltip de la UI.
|
||||||
|
/// </summary>
|
||||||
|
Task<HealthResult> RunCheckAsync(HealthCheckEntry entry, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record HealthResult(
|
||||||
|
HealthSeverity Severity,
|
||||||
|
string Detail);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sévérité d'un check. <see cref="Unknown"/> = check pas configuré
|
||||||
|
/// (Target vide), affiché en gris.
|
||||||
|
/// </summary>
|
||||||
|
public enum HealthSeverity
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Ok,
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
49
src/PSLauncher.Core/Health/OpenVR/IOpenVrService.cs
Normal file
49
src/PSLauncher.Core/Health/OpenVR/IOpenVrService.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
namespace PSLauncher.Core.Health.OpenVRInterop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lecture en arrière-plan (sans démarrer SteamVR ni prendre le compositor)
|
||||||
|
/// de l'état des devices SteamVR : HMD, controllers, trackers, base stations.
|
||||||
|
/// Implémentation : <see cref="OpenVrService"/>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IOpenVrService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Interroge un device par alias :
|
||||||
|
/// <c>hmd</c>, <c>left_controller</c>, <c>right_controller</c>,
|
||||||
|
/// <c>tracker_1..N</c>, <c>base_station_1..N</c>.
|
||||||
|
/// </summary>
|
||||||
|
VrDeviceQueryResult Query(string deviceAlias);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// État de disponibilité de la runtime OpenVR. <c>RuntimeMissing</c> = openvr_api.dll
|
||||||
|
/// introuvable (SteamVR pas installé). <c>HmdAbsent</c> = SteamVR installé mais
|
||||||
|
/// vrserver pas lancé ou aucun HMD branché. <c>InitFailed</c> = API trouvée mais
|
||||||
|
/// VR_InitInternal2 a échoué (cas rare : permission denied, version mismatch).
|
||||||
|
/// <c>Ready</c> = OK, on peut lire les properties.
|
||||||
|
/// </summary>
|
||||||
|
public enum VrRuntimeState
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
RuntimeMissing,
|
||||||
|
HmdAbsent,
|
||||||
|
InitFailed,
|
||||||
|
Ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Résultat d'une requête sur un device. <see cref="Found"/> est false quand
|
||||||
|
/// l'alias résolu ne correspond à aucun device connecté (ex. tracker_3 absent).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record VrDeviceQueryResult(
|
||||||
|
VrRuntimeState RuntimeState,
|
||||||
|
bool Found,
|
||||||
|
string DeviceLabel, // ex. "HMD", "Manette gauche"
|
||||||
|
string? Serial, // serial number ou null
|
||||||
|
OpenVRNative.ETrackedDeviceClass DeviceClass,
|
||||||
|
bool? IsConnected,
|
||||||
|
bool? IsCharging,
|
||||||
|
bool? IsWireless,
|
||||||
|
float? BatteryPercentage, // 0.0–1.0, null si propriété non exposée par le device
|
||||||
|
OpenVRNative.EDeviceActivityLevel? ActivityLevel,
|
||||||
|
string? ErrorDetail);
|
||||||
243
src/PSLauncher.Core/Health/OpenVR/OpenVRNative.cs
Normal file
243
src/PSLauncher.Core/Health/OpenVR/OpenVRNative.cs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Health.OpenVRInterop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// P/Invoke minimal vers <c>openvr_api.dll</c> (SDK Valve OpenVR, BSD-3).
|
||||||
|
/// On NE bundle PAS la DLL : on la résout dynamiquement via la copie installée
|
||||||
|
/// par SteamVR (chemin canonique <c>%LocalAppData%\openvr\openvrpaths.vrpath</c>),
|
||||||
|
/// donc :
|
||||||
|
/// - SteamVR pas installé → la DLL n'est pas trouvée et init renvoie une erreur claire,
|
||||||
|
/// - SteamVR mis à jour → on profite automatiquement de la dernière openvr_api.dll
|
||||||
|
/// sans avoir à respinner un build du launcher.
|
||||||
|
///
|
||||||
|
/// Le SDK OpenVR expose une "flat C API" : quelques fonctions globales pour
|
||||||
|
/// init/shutdown, et une <c>GetGenericInterface("FnTable:IVRSystem_022")</c>
|
||||||
|
/// qui renvoie un pointeur sur une struct C de pointeurs de fonction (vtable).
|
||||||
|
/// Cette classe expose juste ce qu'on consomme dans <see cref="OpenVrService"/> :
|
||||||
|
/// présence de la runtime, présence d'un HMD, et la vtable IVRSystem.
|
||||||
|
/// </summary>
|
||||||
|
public static class OpenVRNative
|
||||||
|
{
|
||||||
|
private const string DllName = "openvr_api";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EVRApplicationType passé à VR_InitInternal2. <c>Background = 4</c> : on
|
||||||
|
/// veut LIRE l'état de SteamVR sans déclencher de rendu, sans devenir une
|
||||||
|
/// scene application, sans monopoliser le compositor.
|
||||||
|
/// </summary>
|
||||||
|
public const int VRApplicationBackground = 4;
|
||||||
|
|
||||||
|
/// <summary>Indices de slots dans la vtable IVRSystem_022. Stables depuis SteamVR 1.10 (2019).</summary>
|
||||||
|
public static class IVRSystemSlot
|
||||||
|
{
|
||||||
|
public const int GetTrackedDeviceActivityLevel = 16;
|
||||||
|
public const int GetTrackedDeviceIndexForControllerRole = 18;
|
||||||
|
public const int GetTrackedDeviceClass = 20;
|
||||||
|
public const int IsTrackedDeviceConnected = 21;
|
||||||
|
public const int GetBoolTrackedDeviceProperty = 22;
|
||||||
|
public const int GetFloatTrackedDeviceProperty = 23;
|
||||||
|
public const int GetStringTrackedDeviceProperty = 28;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indices ETrackedDeviceProperty les plus utiles pour notre usage. Liste
|
||||||
|
/// complète dans <c>openvr.h</c> ; on en garde un sous-ensemble pour ne pas
|
||||||
|
/// dupliquer 800 enums dont 99% inutiles.
|
||||||
|
/// </summary>
|
||||||
|
public static class DeviceProperty
|
||||||
|
{
|
||||||
|
public const int Prop_DeviceIsWireless_Bool = 1003;
|
||||||
|
public const int Prop_DeviceIsCharging_Bool = 1004;
|
||||||
|
public const int Prop_DeviceProvidesBatteryStatus_Bool = 1026; // varie par version, fallback safe sur GetBool
|
||||||
|
public const int Prop_DeviceBatteryPercentage_Float = 1029;
|
||||||
|
public const int Prop_SerialNumber_String = 1002;
|
||||||
|
public const int Prop_TrackingSystemName_String = 1000;
|
||||||
|
public const int Prop_ModelNumber_String = 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Classe OpenVR d'un device tracké. 1=HMD, 2=Controller, 3=GenericTracker, 4=TrackingReference.</summary>
|
||||||
|
public enum ETrackedDeviceClass
|
||||||
|
{
|
||||||
|
Invalid = 0,
|
||||||
|
HMD = 1,
|
||||||
|
Controller = 2,
|
||||||
|
GenericTracker = 3,
|
||||||
|
TrackingReference = 4,
|
||||||
|
DisplayRedirect = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ETrackedControllerRole
|
||||||
|
{
|
||||||
|
Invalid = 0,
|
||||||
|
LeftHand = 1,
|
||||||
|
RightHand = 2,
|
||||||
|
OptOut = 3,
|
||||||
|
Treadmill = 4,
|
||||||
|
Stylus = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Niveau d'activité du device. Idle = en veille, n'envoie plus de pose.
|
||||||
|
/// Standby = posé mais réveille rapidement. UserInteraction = en main.
|
||||||
|
/// </summary>
|
||||||
|
public enum EDeviceActivityLevel
|
||||||
|
{
|
||||||
|
Unknown = -1,
|
||||||
|
Idle = 0,
|
||||||
|
UserInteraction = 1,
|
||||||
|
UserInteraction_Timeout = 2,
|
||||||
|
Standby = 3,
|
||||||
|
Idle_Timeout = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ATTENTION : OpenVR renvoie des bool C++ (1 octet). Sans MarshalAs(I1), le
|
||||||
|
// marshaller .NET lit 4 octets (BOOL Win32) et obtient des valeurs random ;
|
||||||
|
// c'est ce qui faisait passer VR_IsHmdPresent à "true" même casque débranché,
|
||||||
|
// déclenchant ensuite une init qui plante quand SteamVR n'est pas lancé.
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_IsRuntimeInstalled", CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
[return: MarshalAs(UnmanagedType.I1)]
|
||||||
|
public static extern bool VR_IsRuntimeInstalled();
|
||||||
|
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_IsHmdPresent", CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
[return: MarshalAs(UnmanagedType.I1)]
|
||||||
|
public static extern bool VR_IsHmdPresent();
|
||||||
|
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_InitInternal2", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||||
|
public static extern uint VR_InitInternal2(out int peError, int eApplicationType, [MarshalAs(UnmanagedType.LPStr)] string? pStartupInfo);
|
||||||
|
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_ShutdownInternal", CallingConvention = CallingConvention.Cdecl)]
|
||||||
|
public static extern void VR_ShutdownInternal();
|
||||||
|
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_GetGenericInterface", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||||
|
public static extern IntPtr VR_GetGenericInterface([MarshalAs(UnmanagedType.LPStr)] string pchInterfaceVersion, out int peError);
|
||||||
|
|
||||||
|
[DllImport(DllName, EntryPoint = "VR_GetVRInitErrorAsEnglishDescription", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||||
|
public static extern IntPtr VR_GetVRInitErrorAsEnglishDescription(int error);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Récupère un pointeur de fonction depuis la vtable IVRSystem (struct
|
||||||
|
/// contiguë de IntPtr à <paramref name="vtablePtr"/>) au slot demandé.
|
||||||
|
/// </summary>
|
||||||
|
public static IntPtr GetVTableSlot(IntPtr vtablePtr, int slotIndex)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
return ((IntPtr*)vtablePtr)[slotIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetInitErrorDescription(int error)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ptr = VR_GetVRInitErrorAsEnglishDescription(error);
|
||||||
|
return ptr == IntPtr.Zero ? $"unknown ({error})" : Marshal.PtrToStringAnsi(ptr) ?? $"unknown ({error})";
|
||||||
|
}
|
||||||
|
catch { return $"error code {error}"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= DLL RESOLUTION =============
|
||||||
|
|
||||||
|
private static int _resolverInstalled;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Installe (idempotent) un <see cref="DllImportResolver"/> qui, lorsque
|
||||||
|
/// .NET demande "openvr_api", le résout vers la DLL de l'install SteamVR
|
||||||
|
/// active. Pas d'effet de bord si SteamVR est absent — la DllImport
|
||||||
|
/// échouera ensuite comme d'habitude et le service catch.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsureResolverInstalled()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _resolverInstalled, 1) == 1) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
NativeLibrary.SetDllImportResolver(typeof(OpenVRNative).Assembly, ResolveOpenVrDll);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Déjà installé ailleurs, ou plateforme qui ne supporte pas. On laisse
|
||||||
|
// le DllImport faire son travail (cherchera dans le PATH).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IntPtr ResolveOpenVrDll(string libraryName, System.Reflection.Assembly assembly, DllImportSearchPath? searchPath)
|
||||||
|
{
|
||||||
|
if (!string.Equals(libraryName, DllName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return IntPtr.Zero;
|
||||||
|
|
||||||
|
var path = LocateOpenVrDll();
|
||||||
|
if (path is null) return IntPtr.Zero;
|
||||||
|
|
||||||
|
try { return NativeLibrary.Load(path); }
|
||||||
|
catch { return IntPtr.Zero; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cherche openvr_api.dll dans cet ordre :
|
||||||
|
/// 1. <c>%LocalAppData%\openvr\openvrpaths.vrpath</c> (canon écrit par SteamVR au setup).
|
||||||
|
/// 2. Dossier Steam standard : <c>C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static string? LocateOpenVrDll()
|
||||||
|
{
|
||||||
|
// 1. Lire openvrpaths.vrpath (JSON)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||||
|
var pathsFile = Path.Combine(localAppData, "openvr", "openvrpaths.vrpath");
|
||||||
|
if (File.Exists(pathsFile))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(pathsFile, Encoding.UTF8);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
if (doc.RootElement.TryGetProperty("runtime", out var runtimes) && runtimes.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var elt in runtimes.EnumerateArray())
|
||||||
|
{
|
||||||
|
var dir = elt.GetString();
|
||||||
|
if (string.IsNullOrEmpty(dir)) continue;
|
||||||
|
var dll = Path.Combine(dir, "bin", "win64", "openvr_api.dll");
|
||||||
|
if (File.Exists(dll)) return dll;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* fallback */ }
|
||||||
|
|
||||||
|
// 2. Chemin Steam standard
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var steamPath = @"C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\openvr_api.dll";
|
||||||
|
if (File.Exists(steamPath)) return steamPath;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= DELEGATES POUR LES SLOTS UTILISÉS =============
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
public delegate EDeviceActivityLevel GetTrackedDeviceActivityLevelFn(uint deviceIndex);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
public delegate uint GetTrackedDeviceIndexForControllerRoleFn(ETrackedControllerRole role);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
public delegate ETrackedDeviceClass GetTrackedDeviceClassFn(uint deviceIndex);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
[return: MarshalAs(UnmanagedType.I1)]
|
||||||
|
public delegate bool IsTrackedDeviceConnectedFn(uint deviceIndex);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
[return: MarshalAs(UnmanagedType.I1)]
|
||||||
|
public delegate bool GetBoolTrackedDevicePropertyFn(uint deviceIndex, int property, out int peError);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
public delegate float GetFloatTrackedDevicePropertyFn(uint deviceIndex, int property, out int peError);
|
||||||
|
|
||||||
|
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||||
|
public delegate uint GetStringTrackedDevicePropertyFn(uint deviceIndex, int property, IntPtr buffer, uint bufferSize, out int peError);
|
||||||
|
}
|
||||||
495
src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs
Normal file
495
src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
// Alias pour éviter la collision avec le namespace PSLauncher.Core.Process
|
||||||
|
using SysProcess = System.Diagnostics.Process;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Health.OpenVRInterop;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wrapper haut niveau autour de <see cref="OpenVRNative"/>. Singleton
|
||||||
|
/// thread-safe : init paresseuse au premier appel <see cref="Query"/>, état
|
||||||
|
/// mémorisé pour les appels suivants. La vtable IVRSystem reste vivante toute
|
||||||
|
/// la durée de vie du process — coût CPU négligeable au repos, init-shutdown
|
||||||
|
/// répété coûte cher (centaines de ms).
|
||||||
|
///
|
||||||
|
/// Robustesse : tout va dans des try/catch. SteamVR crashé / fermé pendant
|
||||||
|
/// la session → le service détecte au prochain Query (vtable call lève) et
|
||||||
|
/// repasse en <see cref="VrRuntimeState.HmdAbsent"/> proprement, prêt à
|
||||||
|
/// re-tenter au tick suivant.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpenVrService : IOpenVrService, IDisposable
|
||||||
|
{
|
||||||
|
private const string IVRSystemInterfaceVersion = "FnTable:IVRSystem_022";
|
||||||
|
|
||||||
|
private readonly ILogger<OpenVrService> _logger;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
private bool _resolverEnsured;
|
||||||
|
private bool _initAttempted;
|
||||||
|
private VrRuntimeState _lastLoggedState = (VrRuntimeState)(-99); // sentinelle « jamais loggé »
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timestamp UTC de la PREMIÈRE détection de vrserver.exe lancé après une
|
||||||
|
/// période où il était absent. Remis à null si vrserver disparaît. Sert à
|
||||||
|
/// imposer un cooldown avant d'oser init OpenVR : entre l'apparition de
|
||||||
|
/// vrserver dans la liste process et le moment où ses drivers + ses shared
|
||||||
|
/// memories sont prêts à servir des requêtes vtable, il y a typiquement 5-10 s
|
||||||
|
/// de race window pendant laquelle <see cref="OpenVRNative.GetVTableSlot"/>
|
||||||
|
/// + premier appel = AccessViolation (lecture mémoire pas encore mappée).
|
||||||
|
/// </summary>
|
||||||
|
private DateTime? _vrServerFirstSeenUtc;
|
||||||
|
private const double VrServerSettleSeconds = 8.0;
|
||||||
|
private uint _initToken;
|
||||||
|
private IntPtr _vrSystemVTable = IntPtr.Zero;
|
||||||
|
|
||||||
|
// Delegates marshalés une seule fois après init pour éviter le coût
|
||||||
|
// GetDelegateForFunctionPointer à chaque tick.
|
||||||
|
private OpenVRNative.GetTrackedDeviceActivityLevelFn? _fnActivity;
|
||||||
|
private OpenVRNative.GetTrackedDeviceIndexForControllerRoleFn? _fnRoleToIndex;
|
||||||
|
private OpenVRNative.GetTrackedDeviceClassFn? _fnDeviceClass;
|
||||||
|
private OpenVRNative.IsTrackedDeviceConnectedFn? _fnIsConnected;
|
||||||
|
private OpenVRNative.GetBoolTrackedDevicePropertyFn? _fnGetBool;
|
||||||
|
private OpenVRNative.GetFloatTrackedDevicePropertyFn? _fnGetFloat;
|
||||||
|
private OpenVRNative.GetStringTrackedDevicePropertyFn? _fnGetString;
|
||||||
|
|
||||||
|
public OpenVrService(ILogger<OpenVrService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VrDeviceQueryResult Query(string deviceAlias)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
// ============================================================
|
||||||
|
// MODE SAFE : on N'UTILISE PAS la vtable IVRSystem.
|
||||||
|
//
|
||||||
|
// Pourquoi : les fonctions de la vtable (IsTrackedDeviceConnected,
|
||||||
|
// GetBoolTrackedDeviceProperty, etc.) font du SHM/IPC avec vrserver
|
||||||
|
// qui peut être dans un état partiellement initialisé pendant et
|
||||||
|
// après le démarrage de SteamVR. Ces fonctions accèdent à de la
|
||||||
|
// mémoire pas encore mappée → AccessViolation natif → SEH passe
|
||||||
|
// au travers du CLR via Marshal.GetDelegateForFunctionPointer
|
||||||
|
// → process killed sans qu'aucun catch ne se déclenche.
|
||||||
|
//
|
||||||
|
// Repousser le délai de settle ne suffit pas (race window peut
|
||||||
|
// se rouvrir à n'importe quel moment de la session : (re)connexion
|
||||||
|
// d'un device, rechargement d'un driver…). On utilise donc UNIQUEMENT
|
||||||
|
// l'API flat C : VR_IsRuntimeInstalled + VR_IsHmdPresent qui ne
|
||||||
|
// touchent pas au SHM. Limitation : pas de batterie, pas de per-device
|
||||||
|
// info — juste "HMD détecté oui/non". La batterie reviendra en
|
||||||
|
// Phase 1.5 via un subprocess séparé (PSLauncher.VrProbe.exe) qui
|
||||||
|
// peut crasher sans tuer le launcher.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
var label = ResolveLabel(deviceAlias);
|
||||||
|
var state = EnsureReadySafe();
|
||||||
|
LogStateTransition(state);
|
||||||
|
|
||||||
|
string detail;
|
||||||
|
switch (state)
|
||||||
|
{
|
||||||
|
case VrRuntimeState.RuntimeMissing:
|
||||||
|
return new VrDeviceQueryResult(state, false, label, null,
|
||||||
|
OpenVRNative.ETrackedDeviceClass.Invalid,
|
||||||
|
null, null, null, null, null,
|
||||||
|
"SteamVR n'est pas installé sur cette machine");
|
||||||
|
|
||||||
|
case VrRuntimeState.HmdAbsent:
|
||||||
|
return new VrDeviceQueryResult(state, false, label, null,
|
||||||
|
OpenVRNative.ETrackedDeviceClass.Invalid,
|
||||||
|
false, null, null, null, null,
|
||||||
|
"SteamVR n'est pas lancé ou aucun HMD n'est branché");
|
||||||
|
|
||||||
|
case VrRuntimeState.Ready:
|
||||||
|
detail = $"{label} : SteamVR actif, HMD détecté (info détaillée par appareil arrive en Phase 1.5)";
|
||||||
|
return new VrDeviceQueryResult(state, true, label, null,
|
||||||
|
OpenVRNative.ETrackedDeviceClass.HMD,
|
||||||
|
true, null, null, null, null,
|
||||||
|
detail);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new VrDeviceQueryResult(state, false, label, null,
|
||||||
|
OpenVRNative.ETrackedDeviceClass.Invalid,
|
||||||
|
null, null, null, null, null,
|
||||||
|
"Échec d'initialisation OpenVR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Variante safe d'EnsureReady : ne fait JAMAIS de VR_InitInternal2 ni
|
||||||
|
/// de GetGenericInterface. Décide l'état uniquement depuis :
|
||||||
|
/// - <see cref="OpenVRNative.VR_IsRuntimeInstalled"/> (lookup statique
|
||||||
|
/// dans openvr_api.dll, aucun IPC, immédiat),
|
||||||
|
/// - vrserver.exe alive (process check),
|
||||||
|
/// - <see cref="OpenVRNative.VR_IsHmdPresent"/> (consulte un mutex/event
|
||||||
|
/// nommé partagé avec vrserver, mais pas de SHM).
|
||||||
|
/// </summary>
|
||||||
|
private VrRuntimeState EnsureReadySafe()
|
||||||
|
{
|
||||||
|
if (!_resolverEnsured)
|
||||||
|
{
|
||||||
|
OpenVRNative.EnsureResolverInstalled();
|
||||||
|
_resolverEnsured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!OpenVRNative.VR_IsRuntimeInstalled())
|
||||||
|
return VrRuntimeState.RuntimeMissing;
|
||||||
|
}
|
||||||
|
catch (DllNotFoundException) { return VrRuntimeState.RuntimeMissing; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "VR_IsRuntimeInstalled threw");
|
||||||
|
return VrRuntimeState.RuntimeMissing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// vrserver.exe en l'air ?
|
||||||
|
if (!IsVrServerRunning()) return VrRuntimeState.HmdAbsent;
|
||||||
|
|
||||||
|
// Cooldown de settle : on garde le délai même en mode safe parce que
|
||||||
|
// VR_IsHmdPresent consulte un objet de synchro qui peut être pas encore
|
||||||
|
// créé pendant les premières secondes du boot SteamVR.
|
||||||
|
if (!HasVrServerSettled()) return VrRuntimeState.HmdAbsent;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!OpenVRNative.VR_IsHmdPresent()) return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "VR_IsHmdPresent threw");
|
||||||
|
return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return VrRuntimeState.Ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VrRuntimeState EnsureReady()
|
||||||
|
{
|
||||||
|
if (!_resolverEnsured)
|
||||||
|
{
|
||||||
|
OpenVRNative.EnsureResolverInstalled();
|
||||||
|
_resolverEnsured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_vrSystemVTable != IntPtr.Zero) return VrRuntimeState.Ready;
|
||||||
|
|
||||||
|
// Probe runtime + HMD ; les deux sont des appels rapides qui ne déclenchent
|
||||||
|
// pas l'init du compositor même si SteamVR est éteint.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!OpenVRNative.VR_IsRuntimeInstalled())
|
||||||
|
return VrRuntimeState.RuntimeMissing;
|
||||||
|
}
|
||||||
|
catch (DllNotFoundException) { return VrRuntimeState.RuntimeMissing; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "VR_IsRuntimeInstalled threw");
|
||||||
|
return VrRuntimeState.RuntimeMissing;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!OpenVRNative.VR_IsHmdPresent()) return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "VR_IsHmdPresent threw");
|
||||||
|
return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde 1 : vrserver.exe DOIT être actif. Sans lui, VR_InitInternal2 en
|
||||||
|
// mode Background peut soit hang, soit charger des drivers (lighthouse,
|
||||||
|
// oculus_link…) qui plantent en SEH.
|
||||||
|
if (!IsVrServerRunning())
|
||||||
|
{
|
||||||
|
return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garde 2 : on attend que vrserver soit en l'air depuis assez longtemps
|
||||||
|
// pour que ses drivers et ses shared memories soient initialisés.
|
||||||
|
// Sinon : init succeed → premier vtable call → AccessViolation natif
|
||||||
|
// (la shared memory n'est pas encore mappée). Le SEH passe au travers du
|
||||||
|
// CLR via Marshal.GetDelegateForFunctionPointer et tue le process.
|
||||||
|
// 8s = compromis entre réactivité (l'utilisateur a lancé SteamVR, on veut
|
||||||
|
// pas attendre 30 s) et fiabilité (sur SSD c'est ~3-5 s, sur HDD ça peut
|
||||||
|
// monter à 7 s). On garde HmdAbsent affiché pendant le warmup ; quand le
|
||||||
|
// ticker se redéclenche après le délai, on enchaînera proprement sur Ready.
|
||||||
|
if (!HasVrServerSettled())
|
||||||
|
{
|
||||||
|
return VrRuntimeState.HmdAbsent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init en mode Background : on lit les states sans devenir une scene app.
|
||||||
|
// L'init peut prendre 100-500 ms la première fois ; bloquer le caller est OK
|
||||||
|
// car le health loop tourne sur thread dédié.
|
||||||
|
if (_initAttempted && _initToken == 0) return VrRuntimeState.InitFailed;
|
||||||
|
_initAttempted = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_initToken = OpenVRNative.VR_InitInternal2(out var initError, OpenVRNative.VRApplicationBackground, null);
|
||||||
|
if (initError != 0 || _initToken == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("VR_InitInternal2 failed: {Err}", OpenVRNative.GetInitErrorDescription(initError));
|
||||||
|
_initToken = 0;
|
||||||
|
return VrRuntimeState.InitFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
_vrSystemVTable = OpenVRNative.VR_GetGenericInterface(IVRSystemInterfaceVersion, out var ifaceError);
|
||||||
|
if (_vrSystemVTable == IntPtr.Zero || ifaceError != 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("VR_GetGenericInterface({Iface}) failed: {Err}",
|
||||||
|
IVRSystemInterfaceVersion, OpenVRNative.GetInitErrorDescription(ifaceError));
|
||||||
|
ShutdownInternal();
|
||||||
|
return VrRuntimeState.InitFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
BindVTableDelegates();
|
||||||
|
return VrRuntimeState.Ready;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "OpenVR init threw");
|
||||||
|
ShutdownInternal();
|
||||||
|
return VrRuntimeState.InitFailed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BindVTableDelegates()
|
||||||
|
{
|
||||||
|
IntPtr P(int slot) => OpenVRNative.GetVTableSlot(_vrSystemVTable, slot);
|
||||||
|
|
||||||
|
_fnActivity = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetTrackedDeviceActivityLevelFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceActivityLevel));
|
||||||
|
_fnRoleToIndex = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetTrackedDeviceIndexForControllerRoleFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceIndexForControllerRole));
|
||||||
|
_fnDeviceClass = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetTrackedDeviceClassFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceClass));
|
||||||
|
_fnIsConnected = Marshal.GetDelegateForFunctionPointer<OpenVRNative.IsTrackedDeviceConnectedFn>(P(OpenVRNative.IVRSystemSlot.IsTrackedDeviceConnected));
|
||||||
|
_fnGetBool = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetBoolTrackedDevicePropertyFn>(P(OpenVRNative.IVRSystemSlot.GetBoolTrackedDeviceProperty));
|
||||||
|
_fnGetFloat = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetFloatTrackedDevicePropertyFn>(P(OpenVRNative.IVRSystemSlot.GetFloatTrackedDeviceProperty));
|
||||||
|
_fnGetString = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetStringTrackedDevicePropertyFn>(P(OpenVRNative.IVRSystemSlot.GetStringTrackedDeviceProperty));
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint? ResolveDeviceIndex(string alias)
|
||||||
|
{
|
||||||
|
if (_fnRoleToIndex is null || _fnDeviceClass is null) return null;
|
||||||
|
var a = alias.Trim().ToLowerInvariant();
|
||||||
|
const uint K_unMaxTrackedDeviceCount = 64;
|
||||||
|
const uint K_InvalidIndex = 0xFFFFFFFF;
|
||||||
|
|
||||||
|
switch (a)
|
||||||
|
{
|
||||||
|
case "hmd":
|
||||||
|
return 0; // index 0 = HMD principal par convention OpenVR
|
||||||
|
|
||||||
|
case "left_controller":
|
||||||
|
{
|
||||||
|
var idx = _fnRoleToIndex(OpenVRNative.ETrackedControllerRole.LeftHand);
|
||||||
|
return idx == K_InvalidIndex ? null : idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "right_controller":
|
||||||
|
{
|
||||||
|
var idx = _fnRoleToIndex(OpenVRNative.ETrackedControllerRole.RightHand);
|
||||||
|
return idx == K_InvalidIndex ? null : idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tracker_N / base_station_N : on énumère tous les devices et on prend le N-ième de la classe demandée
|
||||||
|
if (TryParseIndexed(a, "tracker_", out var trackerN))
|
||||||
|
return FindNthOfClass(OpenVRNative.ETrackedDeviceClass.GenericTracker, trackerN);
|
||||||
|
|
||||||
|
if (TryParseIndexed(a, "base_station_", out var stationN))
|
||||||
|
return FindNthOfClass(OpenVRNative.ETrackedDeviceClass.TrackingReference, stationN);
|
||||||
|
|
||||||
|
// Fallback : si l'utilisateur a tapé un index brut "0", "1", … on le respecte
|
||||||
|
if (uint.TryParse(a, out var raw) && raw < K_unMaxTrackedDeviceCount)
|
||||||
|
return raw;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseIndexed(string alias, string prefix, out int n)
|
||||||
|
{
|
||||||
|
n = 0;
|
||||||
|
if (!alias.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
var tail = alias[prefix.Length..];
|
||||||
|
return int.TryParse(tail, out n) && n >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private uint? FindNthOfClass(OpenVRNative.ETrackedDeviceClass targetClass, int n)
|
||||||
|
{
|
||||||
|
if (_fnDeviceClass is null) return null;
|
||||||
|
const uint K_unMaxTrackedDeviceCount = 64;
|
||||||
|
var found = 0;
|
||||||
|
for (uint i = 0; i < K_unMaxTrackedDeviceCount; i++)
|
||||||
|
{
|
||||||
|
if (_fnDeviceClass(i) == targetClass)
|
||||||
|
{
|
||||||
|
found++;
|
||||||
|
if (found == n) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveLabel(string alias) => alias.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"hmd" => "HMD",
|
||||||
|
"left_controller" => "Manette gauche",
|
||||||
|
"right_controller" => "Manette droite",
|
||||||
|
var a when a.StartsWith("tracker_") => $"Tracker {a[8..]}",
|
||||||
|
var a when a.StartsWith("base_station_") => $"Base station {a[13..]}",
|
||||||
|
_ => alias,
|
||||||
|
};
|
||||||
|
|
||||||
|
private bool? ReadBoolProperty(uint deviceIndex, int property)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var v = _fnGetBool!(deviceIndex, property, out var err);
|
||||||
|
return err == 0 ? v : null;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private float? ReadFloatProperty(uint deviceIndex, int property)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var v = _fnGetFloat!(deviceIndex, property, out var err);
|
||||||
|
return err == 0 ? v : null;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ReadStringProperty(uint deviceIndex, int property)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var buf = Marshal.AllocHGlobal(256);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var written = _fnGetString!(deviceIndex, property, buf, 256, out var err);
|
||||||
|
if (err != 0 || written == 0) return null;
|
||||||
|
// OpenVR renvoie un C-string ANSI null-terminé
|
||||||
|
return Marshal.PtrToStringAnsi(buf, (int)Math.Min(written, 256u)).TrimEnd('\0');
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Log au niveau INFO la première occurrence d'un état + chaque transition.
|
||||||
|
/// Évite de spammer (un check s'exécute toutes les 5 s, on aurait 700 messages
|
||||||
|
/// par heure si on loggait à chaque tick) tout en laissant une trace claire
|
||||||
|
/// dans le journal au cas où ça re-crasherait — la dernière ligne avant le
|
||||||
|
/// silence de Serilog dirait par exemple « OpenVR state: HmdAbsent ».
|
||||||
|
/// </summary>
|
||||||
|
private void LogStateTransition(VrRuntimeState newState)
|
||||||
|
{
|
||||||
|
if (newState == _lastLoggedState) return;
|
||||||
|
_lastLoggedState = newState;
|
||||||
|
var detail = newState switch
|
||||||
|
{
|
||||||
|
VrRuntimeState.RuntimeMissing => "openvr_api.dll introuvable (SteamVR pas installé)",
|
||||||
|
VrRuntimeState.HmdAbsent => "vrserver.exe pas lancé ou aucun HMD branché",
|
||||||
|
VrRuntimeState.InitFailed => "VR_InitInternal2 a échoué",
|
||||||
|
VrRuntimeState.Ready => "OK, vtable IVRSystem_022 bindée",
|
||||||
|
_ => "?",
|
||||||
|
};
|
||||||
|
_logger.LogInformation("OpenVR state: {State} ({Detail})", newState, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// vrserver.exe est le process central de SteamVR ; sa présence garantit qu'on
|
||||||
|
/// peut init OpenVR sans déclencher de chargement de drivers risqué. Cache de
|
||||||
|
/// 2 s pour ne pas faire un GetProcessesByName à chaque tick.
|
||||||
|
/// </summary>
|
||||||
|
private DateTime _vrServerCacheUntil = DateTime.MinValue;
|
||||||
|
private bool _vrServerCacheValue;
|
||||||
|
|
||||||
|
private bool IsVrServerRunning()
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (now < _vrServerCacheUntil) return _vrServerCacheValue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var procs = SysProcess.GetProcessesByName("vrserver");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_vrServerCacheValue = procs.Length > 0;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var p in procs) p.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "vrserver process probe failed, assuming absent");
|
||||||
|
_vrServerCacheValue = false;
|
||||||
|
}
|
||||||
|
_vrServerCacheUntil = now.AddSeconds(2);
|
||||||
|
|
||||||
|
// Update first-seen tracking : on note l'instant où vrserver apparaît,
|
||||||
|
// et on l'oublie quand il disparaît. C'est ce timestamp qu'on consulte
|
||||||
|
// dans EnsureReady pour décider si la fenêtre de race est passée.
|
||||||
|
if (_vrServerCacheValue && _vrServerFirstSeenUtc is null)
|
||||||
|
{
|
||||||
|
_vrServerFirstSeenUtc = now;
|
||||||
|
_logger.LogInformation("vrserver.exe just appeared — waiting {Secs}s for SteamVR drivers/IPC to settle before init", VrServerSettleSeconds);
|
||||||
|
}
|
||||||
|
else if (!_vrServerCacheValue && _vrServerFirstSeenUtc is not null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("vrserver.exe disappeared — resetting OpenVR session");
|
||||||
|
_vrServerFirstSeenUtc = null;
|
||||||
|
// Si on était Ready, l'invalider : la prochaine session SteamVR exigera un nouvel init
|
||||||
|
if (_initToken != 0) ShutdownInternal();
|
||||||
|
_initAttempted = false;
|
||||||
|
}
|
||||||
|
return _vrServerCacheValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasVrServerSettled()
|
||||||
|
{
|
||||||
|
if (_vrServerFirstSeenUtc is null) return false;
|
||||||
|
return (DateTime.UtcNow - _vrServerFirstSeenUtc.Value).TotalSeconds >= VrServerSettleSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkSessionBroken()
|
||||||
|
{
|
||||||
|
ShutdownInternal();
|
||||||
|
_initAttempted = false; // permettre une retente au prochain tick
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShutdownInternal()
|
||||||
|
{
|
||||||
|
_vrSystemVTable = IntPtr.Zero;
|
||||||
|
_fnActivity = null;
|
||||||
|
_fnRoleToIndex = null;
|
||||||
|
_fnDeviceClass = null;
|
||||||
|
_fnIsConnected = null;
|
||||||
|
_fnGetBool = null;
|
||||||
|
_fnGetFloat = null;
|
||||||
|
_fnGetString = null;
|
||||||
|
|
||||||
|
if (_initToken != 0)
|
||||||
|
{
|
||||||
|
try { OpenVRNative.VR_ShutdownInternal(); } catch { /* ignore */ }
|
||||||
|
_initToken = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_lock) ShutdownInternal();
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/PSLauncher.Core/Health/SystemHealthService.cs
Normal file
203
src/PSLauncher.Core/Health/SystemHealthService.cs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Core.Health.OpenVRInterop;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
// NB : on n'importe PAS System.Diagnostics car le namespace PSLauncher.Core.Process
|
||||||
|
// existe déjà et provoque une ambiguïté avec System.Diagnostics.Process.
|
||||||
|
// On qualifie complètement chaque usage pour rester sans ambiguïté.
|
||||||
|
using SysProcess = System.Diagnostics.Process;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implémentation : Ping ICMP via <see cref="Ping"/>, et listing de processus
|
||||||
|
/// via <see cref="Process.GetProcessesByName(string)"/>. Pas de privilèges
|
||||||
|
/// administrateur requis (le ping classique ICMP fonctionne en user-mode sur
|
||||||
|
/// Windows 10+ ; pas besoin du raw socket).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SystemHealthService : ISystemHealthService
|
||||||
|
{
|
||||||
|
private readonly ILogger<SystemHealthService> _logger;
|
||||||
|
private readonly IOpenVrService _openVr;
|
||||||
|
|
||||||
|
public SystemHealthService(ILogger<SystemHealthService> logger, IOpenVrService openVr)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_openVr = openVr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthResult> RunCheckAsync(HealthCheckEntry entry, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var kind = entry.Kind?.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
// VrDevice n'a PAS besoin d'un Target non vide (HMD = index 0 implicite),
|
||||||
|
// donc on le sort du gate Target-vide.
|
||||||
|
if (kind != "vrdevice" && string.IsNullOrWhiteSpace(entry.Target))
|
||||||
|
return new HealthResult(HealthSeverity.Unknown, "Non configuré (Target vide)");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
"ping" => await CheckPingAsync(entry, ct).ConfigureAwait(false),
|
||||||
|
"process" => CheckProcess(entry),
|
||||||
|
"vrdevice" => CheckVrDevice(entry),
|
||||||
|
_ => new HealthResult(HealthSeverity.Unknown, $"Kind inconnu : '{entry.Kind}'"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Health check {Name} ({Kind} {Target}) failed", entry.Name, entry.Kind, entry.Target);
|
||||||
|
return new HealthResult(HealthSeverity.Error, $"Exception : {ex.GetType().Name} — {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HealthResult> CheckPingAsync(HealthCheckEntry entry, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var ping = new Ping();
|
||||||
|
var timeout = entry.PingTimeoutMs ?? 1000;
|
||||||
|
PingReply reply;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reply = await ping.SendPingAsync(entry.Target, timeout).WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (PingException pex)
|
||||||
|
{
|
||||||
|
return new HealthResult(HealthSeverity.Error, $"Ping {entry.Target} : DNS introuvable ou réseau inaccessible ({pex.InnerException?.Message ?? pex.Message})");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply.Status != IPStatus.Success)
|
||||||
|
return new HealthResult(HealthSeverity.Error,
|
||||||
|
$"Ping {entry.Target} : pas de réponse ({reply.Status})");
|
||||||
|
|
||||||
|
var rtt = reply.RoundtripTime;
|
||||||
|
var warn = entry.PingWarnMs ?? 50;
|
||||||
|
var err = entry.PingErrorMs ?? 200;
|
||||||
|
|
||||||
|
if (rtt > err)
|
||||||
|
return new HealthResult(HealthSeverity.Error,
|
||||||
|
$"Ping {entry.Target} : {rtt} ms (seuil rouge {err} ms)");
|
||||||
|
if (rtt > warn)
|
||||||
|
return new HealthResult(HealthSeverity.Warning,
|
||||||
|
$"Ping {entry.Target} : {rtt} ms (seuil orange {warn} ms)");
|
||||||
|
return new HealthResult(HealthSeverity.Ok,
|
||||||
|
$"Ping {entry.Target} : {rtt} ms");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HealthResult CheckProcess(HealthCheckEntry entry)
|
||||||
|
{
|
||||||
|
// Process.GetProcessesByName accepte le nom sans extension .exe
|
||||||
|
var name = entry.Target.Trim();
|
||||||
|
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
|
name = name[..^4];
|
||||||
|
|
||||||
|
SysProcess[] procs;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
procs = SysProcess.GetProcessesByName(name);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HealthResult(HealthSeverity.Error, $"Lookup processus échoué : {ex.Message}");
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (procs.Length == 0)
|
||||||
|
return new HealthResult(HealthSeverity.Error,
|
||||||
|
$"Processus « {name}.exe » non lancé");
|
||||||
|
return new HealthResult(HealthSeverity.Ok,
|
||||||
|
$"« {name}.exe » est en cours d'exécution ({procs.Length} instance{(procs.Length > 1 ? "s" : "")})");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Process objects portent un handle Win32 à libérer
|
||||||
|
foreach (var p in procs) p.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lit l'état d'un device SteamVR via OpenVR :
|
||||||
|
/// - Runtime absent → Unknown + "SteamVR non installé"
|
||||||
|
/// - HMD absent → Unknown + "SteamVR non lancé / aucun HMD branché"
|
||||||
|
/// - Device cherché non trouvé → Error + "Appareil non détecté"
|
||||||
|
/// - Battery < 15% (et pas en charge) → Error
|
||||||
|
/// - Battery 15-30% (et pas en charge) → Warning
|
||||||
|
/// - Sinon → Ok avec le % batterie
|
||||||
|
///
|
||||||
|
/// Pour les <c>TrackingReference</c> (base stations) qui n'exposent pas de
|
||||||
|
/// batterie, on tombe sur l'état connecté/en veille uniquement.
|
||||||
|
/// </summary>
|
||||||
|
private HealthResult CheckVrDevice(HealthCheckEntry entry)
|
||||||
|
{
|
||||||
|
// Si Target vide pour VrDevice on assume "hmd" — c'est le cas le plus courant
|
||||||
|
var alias = string.IsNullOrWhiteSpace(entry.Target) ? "hmd" : entry.Target.Trim();
|
||||||
|
var result = _openVr.Query(alias);
|
||||||
|
|
||||||
|
if (result.RuntimeState == VrRuntimeState.RuntimeMissing)
|
||||||
|
return new HealthResult(HealthSeverity.Unknown, "SteamVR n'est pas installé sur cette machine");
|
||||||
|
|
||||||
|
if (result.RuntimeState == VrRuntimeState.HmdAbsent)
|
||||||
|
return new HealthResult(HealthSeverity.Error, "SteamVR n'est pas lancé ou aucun HMD n'est branché");
|
||||||
|
|
||||||
|
if (result.RuntimeState == VrRuntimeState.InitFailed)
|
||||||
|
return new HealthResult(HealthSeverity.Error,
|
||||||
|
$"Échec init OpenVR{(result.ErrorDetail is null ? "" : " : " + result.ErrorDetail)}");
|
||||||
|
|
||||||
|
if (!result.Found || result.IsConnected != true)
|
||||||
|
return new HealthResult(HealthSeverity.Error,
|
||||||
|
result.ErrorDetail ?? $"{result.DeviceLabel} non détecté");
|
||||||
|
|
||||||
|
// Mode safe (Phase 1) : OpenVR ne nous donne plus de batterie/activity/serial
|
||||||
|
// pour éviter le crash sur les vtable calls. Quand tous ces champs sont null
|
||||||
|
// mais qu'on est en Ready+Connected, on affiche le message court fourni par
|
||||||
|
// OpenVrService (ex. "HMD détecté — Phase 1.5 fournira la batterie").
|
||||||
|
if (result.BatteryPercentage is null
|
||||||
|
&& result.IsCharging is null
|
||||||
|
&& result.ActivityLevel is null
|
||||||
|
&& string.IsNullOrEmpty(result.Serial))
|
||||||
|
{
|
||||||
|
return new HealthResult(HealthSeverity.Ok,
|
||||||
|
result.ErrorDetail ?? $"{result.DeviceLabel} détecté");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device présent : on assemble un résumé batterie + activité
|
||||||
|
var battery = result.BatteryPercentage;
|
||||||
|
var pctText = battery is null ? "—" : $"{Math.Round(battery.Value * 100)}%";
|
||||||
|
var chargeIcon = result.IsCharging == true ? " ⚡" : "";
|
||||||
|
var serial = string.IsNullOrEmpty(result.Serial) ? "" : $" · {result.Serial}";
|
||||||
|
var activityText = result.ActivityLevel switch
|
||||||
|
{
|
||||||
|
OpenVRNative.EDeviceActivityLevel.UserInteraction => "actif",
|
||||||
|
OpenVRNative.EDeviceActivityLevel.UserInteraction_Timeout => "actif (en pause)",
|
||||||
|
OpenVRNative.EDeviceActivityLevel.Standby => "en veille",
|
||||||
|
OpenVRNative.EDeviceActivityLevel.Idle => "inactif",
|
||||||
|
OpenVRNative.EDeviceActivityLevel.Idle_Timeout => "inactif",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
var detail = $"{result.DeviceLabel} : {pctText}{chargeIcon}" +
|
||||||
|
(activityText is null ? "" : $" — {activityText}") +
|
||||||
|
serial;
|
||||||
|
|
||||||
|
// Severity : prioriser charging et class spéciale (base stations sans batterie)
|
||||||
|
if (result.DeviceClass == OpenVRNative.ETrackedDeviceClass.TrackingReference && battery is null)
|
||||||
|
{
|
||||||
|
// Base station : connectée = OK (même sans info batterie)
|
||||||
|
return new HealthResult(HealthSeverity.Ok, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.IsCharging == true)
|
||||||
|
return new HealthResult(HealthSeverity.Ok, detail);
|
||||||
|
|
||||||
|
if (battery is null)
|
||||||
|
{
|
||||||
|
// Device sans batterie exposée (ex. base station filaire) : on rapporte juste "connecté"
|
||||||
|
return new HealthResult(HealthSeverity.Ok, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pct = battery.Value;
|
||||||
|
if (pct < 0.15f) return new HealthResult(HealthSeverity.Error, detail + " (seuil rouge < 15%)");
|
||||||
|
if (pct < 0.30f) return new HealthResult(HealthSeverity.Warning, detail + " (seuil orange < 30%)");
|
||||||
|
return new HealthResult(HealthSeverity.Ok, detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,4 +8,29 @@ public interface IInstallationRegistry
|
|||||||
InstalledVersion? Get(string version);
|
InstalledVersion? Get(string version);
|
||||||
bool IsInstalled(string version);
|
bool IsInstalled(string version);
|
||||||
Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct);
|
Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Écrit le metadata <c>.proserve-meta.json</c> dans le dossier d'install avec
|
||||||
|
/// le nom du .exe à lancer (récupéré du manifest serveur) + l'identifiant
|
||||||
|
/// d'entrée manifest (<paramref name="entryId"/>) pour détecter les
|
||||||
|
/// collisions inter-channels. À appeler par <c>MainViewModel</c> après
|
||||||
|
/// l'extraction du ZIP, pour que le futur <c>Scan</c> trouve le bon exe et
|
||||||
|
/// pour que l'install guard puisse comparer l'entryId d'un dossier existant
|
||||||
|
/// avant écrasement.
|
||||||
|
/// </summary>
|
||||||
|
Task WriteInstallMetadataAsync(string installDir, string executableName, string? entryId, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Marque les redists comme installés (timestamp UTC) dans la metadata.</summary>
|
||||||
|
Task MarkRedistInstalledAsync(string installDir, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Vrai si la metadata indique que les redists ont déjà été installés.</summary>
|
||||||
|
bool IsRedistInstalled(string installDir);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lit l'<c>entryId</c> stocké dans <c>.proserve-meta.json</c> du dossier
|
||||||
|
/// d'install. Retourne null si le fichier n'existe pas, si la clé n'a
|
||||||
|
/// jamais été écrite (installs antérieurs à ce feature), ou en cas
|
||||||
|
/// d'erreur de parse. Utilisé pour l'install guard anti-collision.
|
||||||
|
/// </summary>
|
||||||
|
string? TryReadEntryId(string installDir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
@@ -6,9 +7,35 @@ namespace PSLauncher.Core.Installations;
|
|||||||
|
|
||||||
public sealed partial class InstallationRegistry : IInstallationRegistry
|
public sealed partial class InstallationRegistry : IInstallationRegistry
|
||||||
{
|
{
|
||||||
private const string ExecutableName = "PROSERVE_UE_5_5.exe";
|
/// <summary>
|
||||||
|
/// Fichier metadata écrit à l'install par MainViewModel. Contient le nom de l'exe
|
||||||
|
/// déclaré dans le manifest pour cette version. Permet à <see cref="Scan"/> de
|
||||||
|
/// retrouver le bon .exe à lancer sans avoir le manifest en mémoire (cas du
|
||||||
|
/// démarrage offline ou avant le 1er Check Updates).
|
||||||
|
/// </summary>
|
||||||
|
public const string MetadataFileName = ".proserve-meta.json";
|
||||||
|
|
||||||
[GeneratedRegex(@"^Proserve v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
|
/// <summary>
|
||||||
|
/// Pattern de fallback quand .proserve-meta.json est absent (vieux installs
|
||||||
|
/// d'avant l'introduction du metadata). Couvre toutes les versions Unreal
|
||||||
|
/// connues (UE_5_5, UE_5_7, etc.). Premier match alphabétique gagne.
|
||||||
|
/// </summary>
|
||||||
|
private const string FallbackExePattern = "PROSERVE_UE_*.exe";
|
||||||
|
|
||||||
|
// IgnoreCase pour matcher à la fois les anciens dossiers "Proserve vX.Y.Z" et les
|
||||||
|
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque). Le 4ᵉ digit
|
||||||
|
// optionnel (?:\.\d+)? permet aux itérations dev/test (1.5.4.13) de
|
||||||
|
// cohabiter avec leur release stable (1.5.4) dans deux dossiers distincts.
|
||||||
|
//
|
||||||
|
// Le groupe optionnel `(-<channel>)?` permet de discriminer plusieurs installs
|
||||||
|
// au même numéro de version mais sur des channels différents :
|
||||||
|
// PROSERVE v1.5.4.32
|
||||||
|
// PROSERVE-firefighter v1.5.4.32
|
||||||
|
// PROSERVE-full v1.5.4.32
|
||||||
|
// Le channel n'est PAS capturé ici — c'est juste un suffixe autorisé dans le
|
||||||
|
// nom de dossier. L'association exacte channel ↔ dossier passe par le lookup
|
||||||
|
// <see cref="VersionManifest.GetInstallFolderName"/> dans MainViewModel.
|
||||||
|
[GeneratedRegex(@"^PROSERVE(-[a-z0-9_-]+)? v(?<v>\d+\.\d+\.\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex VersionFolderRegex();
|
private static partial Regex VersionFolderRegex();
|
||||||
|
|
||||||
private readonly ILogger<InstallationRegistry> _logger;
|
private readonly ILogger<InstallationRegistry> _logger;
|
||||||
@@ -36,10 +63,10 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
|
|||||||
var match = VersionFolderRegex().Match(name);
|
var match = VersionFolderRegex().Match(name);
|
||||||
if (!match.Success) continue;
|
if (!match.Success) continue;
|
||||||
|
|
||||||
var exe = Path.Combine(dir, ExecutableName);
|
var exe = ResolveExecutable(dir);
|
||||||
if (!File.Exists(exe))
|
if (exe is null)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Skipped {Dir}: missing {Exe}", dir, ExecutableName);
|
_logger.LogDebug("Skipped {Dir}: no executable found (no .proserve-meta.json + no fallback match)", dir);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +76,8 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
|
|||||||
FolderPath: dir,
|
FolderPath: dir,
|
||||||
ExecutablePath: exe,
|
ExecutablePath: exe,
|
||||||
InstalledAt: info.CreationTimeUtc,
|
InstalledAt: info.CreationTimeUtc,
|
||||||
SizeBytes: SafeDirectorySize(dir)));
|
SizeBytes: SafeDirectorySize(dir),
|
||||||
|
EntryId: TryReadEntryId(dir)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
@@ -57,6 +85,145 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Résout le .exe à lancer pour un dossier d'install donné. Stratégie :
|
||||||
|
/// 1. Lit <c>.proserve-meta.json</c> (écrit par MainViewModel à l'install,
|
||||||
|
/// contient l'exe déclaré dans le manifest serveur — source canonique).
|
||||||
|
/// 2. Fallback glob <c>PROSERVE_UE_*.exe</c> pour les vieux installs sans meta.
|
||||||
|
/// 3. Null si aucun match (dossier corrompu ou pas un install PROSERVE).
|
||||||
|
/// </summary>
|
||||||
|
private string? ResolveExecutable(string installDir)
|
||||||
|
{
|
||||||
|
// Tentative 1 : metadata écrite par le launcher au moment de l'install
|
||||||
|
var metaPath = Path.Combine(installDir, MetadataFileName);
|
||||||
|
if (File.Exists(metaPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var raw = File.ReadAllText(metaPath);
|
||||||
|
var meta = JsonSerializer.Deserialize<InstallMetadata>(raw);
|
||||||
|
if (meta is { Executable: var exeName } && !string.IsNullOrWhiteSpace(exeName))
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(installDir, exeName);
|
||||||
|
if (File.Exists(candidate)) return candidate;
|
||||||
|
_logger.LogWarning("Metadata declares {Exe} but file missing in {Dir} — falling back to glob", exeName, installDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to parse {Meta} (will fall back to glob)", metaPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentative 2 : glob pattern (couvre vieux installs sans metadata)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var matches = Directory.GetFiles(installDir, FallbackExePattern, SearchOption.TopDirectoryOnly);
|
||||||
|
if (matches.Length > 0)
|
||||||
|
{
|
||||||
|
Array.Sort(matches, StringComparer.OrdinalIgnoreCase);
|
||||||
|
return matches[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* ignore IO error */ }
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Écrit le fichier metadata <c>.proserve-meta.json</c> dans le dossier d'install
|
||||||
|
/// fraîchement extrait. Appelé par <see cref="MainViewModel"/> après le ZipInstaller,
|
||||||
|
/// avec l'exe déclaré dans le manifest (<c>VersionManifest.Executable</c>) et
|
||||||
|
/// l'<paramref name="entryId"/> de l'entrée manifest source. L'entryId sert au
|
||||||
|
/// pre-install guard : un futur install qui vise le même dossier avec un id
|
||||||
|
/// différent (= une autre entrée manifest sur un channel différent partageant
|
||||||
|
/// le numéro de version) sera refusé pour éviter l'écrasement silencieux.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteInstallMetadataAsync(string installDir, string executableName, string? entryId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(installDir, MetadataFileName);
|
||||||
|
// Préserve un éventuel RedistInstalledAt existant (relance install sans
|
||||||
|
// ré-installer les redists → on ne veut pas perdre le timestamp).
|
||||||
|
InstallMetadata? existing = null;
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
try { existing = JsonSerializer.Deserialize<InstallMetadata>(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); }
|
||||||
|
catch { existing = null; }
|
||||||
|
}
|
||||||
|
var meta = new InstallMetadata
|
||||||
|
{
|
||||||
|
Executable = executableName,
|
||||||
|
EntryId = entryId,
|
||||||
|
RedistInstalledAt = existing?.RedistInstalledAt,
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string? TryReadEntryId(string installDir)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(installDir, MetadataFileName);
|
||||||
|
if (!File.Exists(path)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = JsonSerializer.Deserialize<InstallMetadata>(File.ReadAllText(path));
|
||||||
|
return string.IsNullOrWhiteSpace(meta?.EntryId) ? null : meta.EntryId;
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marque dans la metadata qu'on a installé avec succès les redists Unreal
|
||||||
|
/// (contenu de <c>_redist/</c> de la version). Évite de re-runner les UAC
|
||||||
|
/// popups si l'utilisateur réinstalle la même version.
|
||||||
|
/// </summary>
|
||||||
|
public async Task MarkRedistInstalledAsync(string installDir, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(installDir, MetadataFileName);
|
||||||
|
InstallMetadata? meta = null;
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
try { meta = JsonSerializer.Deserialize<InstallMetadata>(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); }
|
||||||
|
catch { meta = null; }
|
||||||
|
}
|
||||||
|
meta ??= new InstallMetadata();
|
||||||
|
meta.RedistInstalledAt = DateTime.UtcNow;
|
||||||
|
var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vrai si l'install dir a déjà eu son <c>_redist/</c> installé une fois (via
|
||||||
|
/// metadata). Permet de skip le UAC popup chain à chaque réinstall de la même
|
||||||
|
/// version.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsRedistInstalled(string installDir)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(installDir, MetadataFileName);
|
||||||
|
if (!File.Exists(path)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = JsonSerializer.Deserialize<InstallMetadata>(File.ReadAllText(path));
|
||||||
|
return meta?.RedistInstalledAt is not null;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InstallMetadata
|
||||||
|
{
|
||||||
|
public string Executable { get; set; } = string.Empty;
|
||||||
|
public DateTime? RedistInstalledAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Id de l'entrée manifest source (<see cref="VersionManifest.Id"/>). Null
|
||||||
|
/// pour les installs antérieurs à cette feature (rétro-compat). Utilisé
|
||||||
|
/// par l'install guard : refuse un install si un autre entryId est déjà
|
||||||
|
/// présent dans le dossier cible.
|
||||||
|
/// </summary>
|
||||||
|
public string? EntryId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public InstalledVersion? Get(string version) =>
|
public InstalledVersion? Get(string version) =>
|
||||||
Scan().FirstOrDefault(v => v.Version == version);
|
Scan().FirstOrDefault(v => v.Version == version);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,52 @@
|
|||||||
namespace PSLauncher.Core.Installations;
|
namespace PSLauncher.Core.Installations;
|
||||||
|
|
||||||
public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable<SemVer>
|
/// <summary>
|
||||||
|
/// Version produit PROSERVE. Formats acceptés :
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>X.Y.Z</c> (3 digits — releases publiques)</item>
|
||||||
|
/// <item><c>X.Y.Z.B</c> (4 digits — itérations dev/test internes)</item>
|
||||||
|
/// </list>
|
||||||
|
/// Le 4ᵉ digit <see cref="Build"/> est sémantiquement "post-release" :
|
||||||
|
/// <c>1.5.4 == 1.5.4.0 < 1.5.4.1 < 1.5.4.13 < 1.5.5</c>.
|
||||||
|
/// L'absence de 4ᵉ digit est équivalente à <c>.0</c> pour la comparaison,
|
||||||
|
/// mais conservée distincte dans <see cref="ToString"/> (= <c>1.5.4</c>,
|
||||||
|
/// PAS <c>1.5.4.0</c>) pour ne pas re-écrire les versions historiques
|
||||||
|
/// du manifest qui n'avaient que 3 digits.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct SemVer(int Major, int Minor, int Patch, int Build = 0) : IComparable<SemVer>
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// True si la version originale avait un 4ᵉ digit explicite (même <c>.0</c>).
|
||||||
|
/// Sert uniquement à <see cref="ToString"/> pour préserver le format d'origine
|
||||||
|
/// lors d'un round-trip Parse → ToString.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasExplicitBuild { get; init; }
|
||||||
|
|
||||||
public static SemVer Parse(string s)
|
public static SemVer Parse(string s)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s)) return new SemVer(0, 0, 0);
|
||||||
|
|
||||||
var parts = s.Split('.');
|
var parts = s.Split('.');
|
||||||
if (parts.Length != 3
|
// 3 digits = release publique (X.Y.Z), 4 digits = itération dev (X.Y.Z.B).
|
||||||
|| !int.TryParse(parts[0], out var maj)
|
// Tout autre format = parse-fail → on retourne 0.0.0 (silencieux, comme
|
||||||
|
// l'ancien comportement, pour ne pas casser sur un manifest exotique).
|
||||||
|
if (parts.Length < 3 || parts.Length > 4) return new SemVer(0, 0, 0);
|
||||||
|
if (!int.TryParse(parts[0], out var maj)
|
||||||
|| !int.TryParse(parts[1], out var min)
|
|| !int.TryParse(parts[1], out var min)
|
||||||
|| !int.TryParse(parts[2], out var pat))
|
|| !int.TryParse(parts[2], out var pat))
|
||||||
{
|
{
|
||||||
return new SemVer(0, 0, 0);
|
return new SemVer(0, 0, 0);
|
||||||
}
|
}
|
||||||
return new SemVer(maj, min, pat);
|
if (parts.Length == 3)
|
||||||
|
{
|
||||||
|
return new SemVer(maj, min, pat, 0) { HasExplicitBuild = false };
|
||||||
|
}
|
||||||
|
// 4 digits — itération dev
|
||||||
|
if (!int.TryParse(parts[3], out var build))
|
||||||
|
{
|
||||||
|
return new SemVer(0, 0, 0);
|
||||||
|
}
|
||||||
|
return new SemVer(maj, min, pat, build) { HasExplicitBuild = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
public int CompareTo(SemVer other)
|
public int CompareTo(SemVer other)
|
||||||
@@ -21,8 +55,13 @@ public readonly record struct SemVer(int Major, int Minor, int Patch) : ICompara
|
|||||||
if (c != 0) return c;
|
if (c != 0) return c;
|
||||||
c = Minor.CompareTo(other.Minor);
|
c = Minor.CompareTo(other.Minor);
|
||||||
if (c != 0) return c;
|
if (c != 0) return c;
|
||||||
return Patch.CompareTo(other.Patch);
|
c = Patch.CompareTo(other.Patch);
|
||||||
|
if (c != 0) return c;
|
||||||
|
// Build absent = 0 implicite → 1.5.4 == 1.5.4.0 < 1.5.4.1
|
||||||
|
return Build.CompareTo(other.Build);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => $"{Major}.{Minor}.{Patch}";
|
public override string ToString() => HasExplicitBuild || Build > 0
|
||||||
|
? $"{Major}.{Minor}.{Patch}.{Build}"
|
||||||
|
: $"{Major}.{Minor}.{Patch}";
|
||||||
}
|
}
|
||||||
|
|||||||
55
src/PSLauncher.Core/Installations/VersionOrder.cs
Normal file
55
src/PSLauncher.Core/Installations/VersionOrder.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Installations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comparaison de versions PROSERVE qui prend en compte le flag <c>isBeta</c>
|
||||||
|
/// du manifest en plus du SemVer numérique.
|
||||||
|
///
|
||||||
|
/// Règle : au même préfixe 3-digit <c>X.Y.Z</c>, une version <c>isBeta=false</c>
|
||||||
|
/// est considérée SUPÉRIEURE à une version <c>isBeta=true</c>, quel que soit le
|
||||||
|
/// 4ᵉ digit. Cas d'usage typique : après avoir itéré des builds beta <c>1.5.4.30
|
||||||
|
/// .31 .32</c>, l'opérateur publie <c>1.5.4</c> non-beta comme release finale.
|
||||||
|
/// Sous SemVer pur, <c>1.5.4 == 1.5.4.0 < 1.5.4.32</c> donc le launcher
|
||||||
|
/// resterait pointé sur la beta ; ce helper inverse la comparaison pour ce
|
||||||
|
/// cas précis afin que la release finale soit considérée comme LA version
|
||||||
|
/// courante.
|
||||||
|
///
|
||||||
|
/// Hors ce cas (préfixes 3-digit différents, ou même statut beta des deux
|
||||||
|
/// côtés), on retombe sur <see cref="SemVer.CompareTo"/> — donc :
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>1.5.4 (non-beta)</c> vs <c>1.5.4.32 (beta)</c> → 1.5.4 gagne (règle spéciale)</item>
|
||||||
|
/// <item><c>1.5.4.30 (beta)</c> vs <c>1.5.4.32 (beta)</c> → 1.5.4.32 gagne (SemVer)</item>
|
||||||
|
/// <item><c>1.5.4 (non-beta)</c> vs <c>1.5.5.10 (beta)</c> → 1.5.5.10 gagne (SemVer, préfixes différents)</item>
|
||||||
|
/// <item><c>1.5.4</c> vs <c>1.5.5</c> → 1.5.5 gagne (SemVer)</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public static class VersionOrder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retourne <0 si A < B, 0 si égales, >0 si A > B. Voir la doc de
|
||||||
|
/// <see cref="VersionOrder"/> pour la règle isBeta.
|
||||||
|
/// </summary>
|
||||||
|
public static int Compare(string versionA, bool isBetaA, string versionB, bool isBetaB)
|
||||||
|
{
|
||||||
|
var svA = SemVer.Parse(versionA);
|
||||||
|
var svB = SemVer.Parse(versionB);
|
||||||
|
|
||||||
|
// Cas spécial : même préfixe 3-digit ET statut beta différent.
|
||||||
|
// La version non-beta l'emporte, indépendamment du 4ᵉ digit (traité
|
||||||
|
// comme un suffixe pré-release dans ce contexte particulier).
|
||||||
|
if (svA.Major == svB.Major
|
||||||
|
&& svA.Minor == svB.Minor
|
||||||
|
&& svA.Patch == svB.Patch
|
||||||
|
&& isBetaA != isBetaB)
|
||||||
|
{
|
||||||
|
return isBetaA ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return svA.CompareTo(svB);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Overload pratique pour une <see cref="VersionManifest"/>.</summary>
|
||||||
|
public static int Compare(VersionManifest a, VersionManifest b)
|
||||||
|
=> Compare(a.Version, a.IsBeta, b.Version, b.IsBeta);
|
||||||
|
}
|
||||||
@@ -26,11 +26,70 @@ public sealed class ZipInstaller : IZipInstaller
|
|||||||
{
|
{
|
||||||
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false);
|
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Si la version est DÉJÀ installée (cas typique : ré-installation
|
||||||
|
// d'un build sans bumper le numéro de version pendant des tests),
|
||||||
|
// on renomme l'existant en backup horodaté pour ne pas bloquer
|
||||||
|
// l'install. Le backup est supprimé en arrière-plan après que le
|
||||||
|
// swap soit confirmé (sinon on doublerait la conso disque le temps
|
||||||
|
// de la copie). Si le swap échoue, on restaure le backup.
|
||||||
|
string? renamedBackup = null;
|
||||||
if (Directory.Exists(targetFolder))
|
if (Directory.Exists(targetFolder))
|
||||||
throw new InvalidOperationException($"Le dossier cible existe déjà : {targetFolder}");
|
{
|
||||||
|
renamedBackup = $"{targetFolder}.bak-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
|
||||||
|
Directory.Move(targetFolder, renamedBackup);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Existing install at {Target} renamed to {Backup} before re-install",
|
||||||
|
targetFolder, renamedBackup);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Move(stagingFolder, targetFolder);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Rollback : restaure le backup si on a échoué après le rename
|
||||||
|
if (renamedBackup is not null && Directory.Exists(renamedBackup) && !Directory.Exists(targetFolder))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Move(renamedBackup, targetFolder);
|
||||||
|
_logger.LogWarning("Restored previous install from {Backup} after move failure", renamedBackup);
|
||||||
|
}
|
||||||
|
catch (Exception restoreEx)
|
||||||
|
{
|
||||||
|
_logger.LogError(restoreEx,
|
||||||
|
"FAILED to restore backup {Backup} — manual recovery needed", renamedBackup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
Directory.Move(stagingFolder, targetFolder);
|
|
||||||
_logger.LogInformation("Installed to {Target}", targetFolder);
|
_logger.LogInformation("Installed to {Target}", targetFolder);
|
||||||
|
|
||||||
|
// Cleanup background du backup (14 Go d'install × N réinstallations
|
||||||
|
// remplirait le disque sinon). Best-effort : si on ne peut pas
|
||||||
|
// delete (handle Windows tenace, AV qui scanne), on log et on
|
||||||
|
// laisse à l'opérateur (le nom .bak-{ts} le rend repérable).
|
||||||
|
if (renamedBackup is not null)
|
||||||
|
{
|
||||||
|
var bakToDelete = renamedBackup;
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(bakToDelete, recursive: true);
|
||||||
|
_logger.LogInformation("Cleaned up old install backup {Backup}", bakToDelete);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Could not delete old install backup {Backup} — operator can remove manually",
|
||||||
|
bakToDelete);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return targetFolder;
|
return targetFolder;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -1,26 +1,63 @@
|
|||||||
|
using System.Buffers;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace PSLauncher.Core.Integrity;
|
namespace PSLauncher.Core.Integrity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calcul SHA-256 streaming optimisé pour gros fichiers (14+ Go) :
|
||||||
|
/// - Buffer 16 MiB (vs 1 MiB historique) pour saturer le throughput SSD/HDD ;
|
||||||
|
/// - <see cref="FileOptions.SequentialScan"/> hint pour que Windows fasse du read-ahead agressif ;
|
||||||
|
/// - <see cref="FileOptions.Asynchronous"/> pour ne pas bloquer le ThreadPool en I/O sync ;
|
||||||
|
/// - <see cref="IncrementalHash"/> (au lieu de TransformBlock/TransformFinalBlock) qui est plus moderne,
|
||||||
|
/// moins GC-heavy, et bénéficie sur .NET 8 des intrinsics SHA hardware (SHA-NI Intel/AMD,
|
||||||
|
/// ARMv8 Crypto Extensions) quand la CPU les supporte → typiquement 2-5× plus rapide.
|
||||||
|
/// - Double-buffering : on lit le buffer N+1 pendant qu'on hash le buffer N, ce qui permet de
|
||||||
|
/// recouvrir CPU (hash) et I/O (read) sur les machines où ils ne sont pas le même goulot.
|
||||||
|
///
|
||||||
|
/// Sur un build 14 Go, l'impact typique constaté :
|
||||||
|
/// ~1 MiB buffer + TransformBlock : 60-180 s
|
||||||
|
/// ~16 MiB buffer + IncrementalHash + double-buffer : 15-40 s
|
||||||
|
/// </summary>
|
||||||
public sealed class IntegrityService : IIntegrityService
|
public sealed class IntegrityService : IIntegrityService
|
||||||
{
|
{
|
||||||
|
private const int BufferSize = 16 << 20; // 16 MiB
|
||||||
|
|
||||||
public async Task<string> ComputeSha256Async(string filePath, IProgress<double>? progress, CancellationToken ct)
|
public async Task<string> ComputeSha256Async(string filePath, IProgress<double>? progress, CancellationToken ct)
|
||||||
{
|
{
|
||||||
const int bufferSize = 1 << 20; // 1 MiB
|
var fileOptions = FileOptions.Asynchronous | FileOptions.SequentialScan;
|
||||||
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, useAsync: true);
|
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, fileOptions);
|
||||||
using var sha = SHA256.Create();
|
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||||
|
|
||||||
var buffer = new byte[bufferSize];
|
// Pool pour ne pas allouer 32 Mo à chaque appel.
|
||||||
long total = fs.Length;
|
var pool = ArrayPool<byte>.Shared;
|
||||||
long read = 0;
|
byte[] bufA = pool.Rent(BufferSize);
|
||||||
int n;
|
byte[] bufB = pool.Rent(BufferSize);
|
||||||
while ((n = await fs.ReadAsync(buffer.AsMemory(0, bufferSize), ct).ConfigureAwait(false)) > 0)
|
try
|
||||||
{
|
{
|
||||||
sha.TransformBlock(buffer, 0, n, null, 0);
|
long total = fs.Length;
|
||||||
read += n;
|
long read = 0;
|
||||||
if (total > 0) progress?.Report((double)read / total);
|
|
||||||
|
// Premier read amorce le pipeline.
|
||||||
|
int n = await fs.ReadAsync(bufA.AsMemory(0, BufferSize), ct).ConfigureAwait(false);
|
||||||
|
while (n > 0)
|
||||||
|
{
|
||||||
|
// Lance le prochain read en parallèle pendant qu'on hash le buffer courant.
|
||||||
|
var nextReadTask = fs.ReadAsync(bufB.AsMemory(0, BufferSize), ct);
|
||||||
|
hasher.AppendData(bufA, 0, n);
|
||||||
|
read += n;
|
||||||
|
if (total > 0) progress?.Report((double)read / total);
|
||||||
|
int next = await nextReadTask.ConfigureAwait(false);
|
||||||
|
// Swap : bufB devient le buffer courant, bufA le futur prochain read.
|
||||||
|
(bufA, bufB) = (bufB, bufA);
|
||||||
|
n = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Convert.ToHexString(hasher.GetHashAndReset()).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
pool.Return(bufA);
|
||||||
|
pool.Return(bufB);
|
||||||
}
|
}
|
||||||
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
|
||||||
return Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/PSLauncher.Core/Lan/ILanCacheServer.cs
Normal file
24
src/PSLauncher.Core/Lan/ILanCacheServer.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
namespace PSLauncher.Core.Lan;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serveur HTTP local qui expose les ZIPs cachés aux peers du LAN.
|
||||||
|
///
|
||||||
|
/// Routes :
|
||||||
|
/// - <c>GET /v1/has/{version}</c> → JSON <c>{sha256, size, mtime}</c> ou 404
|
||||||
|
/// - <c>GET /v1/zip/{version}</c> → stream du ZIP avec support Range
|
||||||
|
///
|
||||||
|
/// Sécurité :
|
||||||
|
/// - Bind sur les IPs locales LAN uniquement (pas <c>+</c> ni <c>*</c>) → pas
|
||||||
|
/// besoin d'URL ACL admin pour démarrer.
|
||||||
|
/// - Filtre <c>RemoteEndPoint</c> : refuse 403 toute requête venant d'une IP
|
||||||
|
/// non-RFC1918 / non-link-local.
|
||||||
|
/// - Validation regex stricte sur <c>{version}</c> avant tout File.IO.
|
||||||
|
/// - SemaphoreSlim(4) pour limiter à 4 DLs simultanés et éviter de DoS le PC
|
||||||
|
/// serveur sous charge.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILanCacheServer
|
||||||
|
{
|
||||||
|
bool IsRunning { get; }
|
||||||
|
int? BoundPort { get; }
|
||||||
|
IReadOnlyList<string> BoundEndpoints { get; }
|
||||||
|
}
|
||||||
37
src/PSLauncher.Core/Lan/ILanDiscoveryService.cs
Normal file
37
src/PSLauncher.Core/Lan/ILanDiscoveryService.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
namespace PSLauncher.Core.Lan;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Un peer découvert via le broadcast UDP. <see cref="LastSeenUtc"/> sert au TTL :
|
||||||
|
/// si le peer ne renvoie plus de beacon pendant > 60 s, il est considéré offline
|
||||||
|
/// et n'apparaît plus dans <see cref="ILanDiscoveryService.ListDiscovered"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DiscoveredPeer(
|
||||||
|
string Host,
|
||||||
|
int Port,
|
||||||
|
IReadOnlyList<string> Versions,
|
||||||
|
DateTime LastSeenUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-découverte des peers PSLauncher du LAN via UDP broadcast.
|
||||||
|
///
|
||||||
|
/// Mode serveur (ServerEnabled=true) : émet un beacon JSON sur 255.255.255.255:DiscoveryPort
|
||||||
|
/// toutes les 15 s avec son host/port HTTP + la liste des versions cachées.
|
||||||
|
///
|
||||||
|
/// Mode client (ClientEnabled=true OU ServerEnabled=true) : écoute sur le port,
|
||||||
|
/// parse les beacons, maintient un dictionnaire interne avec TTL.
|
||||||
|
///
|
||||||
|
/// Note : le mode serveur écoute aussi (pour ne pas avoir à demander à l'OS deux
|
||||||
|
/// fois le port via SO_REUSEADDR), mais il ignore ses propres beacons.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILanDiscoveryService
|
||||||
|
{
|
||||||
|
/// <summary>Liste des peers vus récemment (lastSeen >= now - 60s). Vide si discovery désactivée.</summary>
|
||||||
|
IReadOnlyList<DiscoveredPeer> ListDiscovered();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Peers connus de runs précédents, chargés depuis le cache disque au démarrage
|
||||||
|
/// (max 1 semaine). Utile en cold start pour éviter d'attendre les 15 s du
|
||||||
|
/// premier beacon UDP avant qu'un peer apparaisse.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<DiscoveredPeer> ListKnown();
|
||||||
|
}
|
||||||
21
src/PSLauncher.Core/Lan/IPeerManifestFetcher.cs
Normal file
21
src/PSLauncher.Core/Lan/IPeerManifestFetcher.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace PSLauncher.Core.Lan;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Récupère le manifest depuis un peer LAN. Sert quand le client n'a pas
|
||||||
|
/// d'accès Internet : il interroge les peers découverts via UDP discovery
|
||||||
|
/// (et les peers manuels en config) pour trouver un PC ayant déjà fetché le
|
||||||
|
/// manifest depuis OVH récemment.
|
||||||
|
///
|
||||||
|
/// La sécurité reste celle du manifest signé Ed25519 — le peer pourrait
|
||||||
|
/// servir n'importe quel JSON, mais le launcher rejettera tout manifest non
|
||||||
|
/// signé correctement (ou tout ZIP dont le SHA-256 ne match pas).
|
||||||
|
/// </summary>
|
||||||
|
public interface IPeerManifestFetcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tente de récupérer le manifest brut (JSON, byte-for-byte) depuis le
|
||||||
|
/// premier peer qui répond. Null si aucun peer atteignable, no-op si
|
||||||
|
/// ClientEnabled=false.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> TryFetchRawAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user