Compare commits
102 Commits
8988d130ac
...
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 |
35
.gitignore
vendored
35
.gitignore
vendored
@@ -28,19 +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).
|
||||||
/PSLauncher-*.exe
|
/PS_Launcher*.exe
|
||||||
/PSLauncher*.bak
|
/PS_Launcher*.bak
|
||||||
|
|
||||||
# Sortie de l'installeur Inno Setup
|
# Sortie de l'installeur Inno Setup
|
||||||
/installer/output/
|
/installer/output/
|
||||||
|
|
||||||
|
# WebView2 user data folder créé à côté de l'exe au runtime (cache, cookies,
|
||||||
|
# sync data Edge Chromium). Peut faire plusieurs Mo. Le pattern * couvre les
|
||||||
|
# 2 noms historiques (PSLauncher.exe.WebView2, PS_Launcher.exe.WebView2) et
|
||||||
|
# tout build de test ponctuel avec un autre nom d'exe.
|
||||||
|
*.exe.WebView2/
|
||||||
|
|
||||||
|
# Fichiers de lock Office (Word/PowerPoint) commençant par ~$
|
||||||
|
~$*
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ setlocal
|
|||||||
set "REPO=%~dp0"
|
set "REPO=%~dp0"
|
||||||
echo.
|
echo.
|
||||||
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
|
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
|
||||||
taskkill /F /IM PSLauncher.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
|
||||||
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
||||||
echo.
|
echo.
|
||||||
@@ -22,8 +22,8 @@ if %ERRORLEVEL% NEQ 0 goto :fail
|
|||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo [OK] Les deux exes sont disponibles a la racine du repo :
|
echo [OK] Les deux exes sont disponibles a la racine du repo :
|
||||||
echo %REPO%PSLauncher.exe
|
echo %REPO%PS_Launcher.exe
|
||||||
echo %REPO%PSLauncher.Updater.exe
|
echo %REPO%PS_Launcher.Updater.exe
|
||||||
echo.
|
echo.
|
||||||
pause
|
pause
|
||||||
endlocal & exit /b 0
|
endlocal & exit /b 0
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ setlocal
|
|||||||
set "SCRIPT_DIR=%~dp0"
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
|
||||||
REM Termine les eventuelles instances en cours qui verrouilleraient les .exe
|
REM Termine les eventuelles instances en cours qui verrouilleraient les .exe
|
||||||
taskkill /F /IM PSLauncher.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
|
||||||
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
|
||||||
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%installer\build-installer.ps1" %*
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%installer\build-installer.ps1" %*
|
||||||
set "EXITCODE=%ERRORLEVEL%"
|
set "EXITCODE=%ERRORLEVEL%"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM Build du launcher principal (PSLauncher.exe).
|
REM Build du launcher principal (PS_Launcher.exe).
|
||||||
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe (~78 Mo single-file self-contained)
|
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PS_Launcher.exe (~78 Mo single-file self-contained)
|
||||||
|
|
||||||
setlocal
|
setlocal
|
||||||
set "REPO=%~dp0"
|
set "REPO=%~dp0"
|
||||||
echo.
|
echo.
|
||||||
echo ==^> Termine les eventuelles instances de PSLauncher qui verrouilleraient les .exe
|
echo ==^> Termine les eventuelles instances de PS_Launcher qui verrouilleraient les .exe
|
||||||
taskkill /F /IM PSLauncher.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
|
||||||
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
echo ==^> dotnet publish PSLauncher.App (Release single-file)
|
||||||
echo.
|
echo.
|
||||||
@@ -19,9 +19,9 @@ echo.
|
|||||||
if %EXITCODE% NEQ 0 (
|
if %EXITCODE% NEQ 0 (
|
||||||
echo [ECHEC] Build du launcher echoue avec le code %EXITCODE%.
|
echo [ECHEC] Build du launcher echoue avec le code %EXITCODE%.
|
||||||
) else (
|
) else (
|
||||||
echo [OK] PSLauncher.exe disponible a la racine du repo :
|
echo [OK] PS_Launcher.exe disponible a la racine du repo :
|
||||||
echo %REPO%PSLauncher.exe
|
echo %REPO%PS_Launcher.exe
|
||||||
)
|
)
|
||||||
echo.
|
echo.
|
||||||
pause
|
pause
|
||||||
endlocal & exit /b %EXITCODE%
|
endlocal ^& exit /b %EXITCODE%
|
||||||
|
|||||||
137
build-proserve-zip.bat
Normal file
137
build-proserve-zip.bat
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
@echo off
|
||||||
|
REM ========================================================================
|
||||||
|
REM build-proserve-zip.bat
|
||||||
|
REM Crée le ZIP de release d'une version PROSERVE prêt à uploader sur OVH.
|
||||||
|
REM
|
||||||
|
REM Usage :
|
||||||
|
REM build-proserve-zip.bat 1.5.3
|
||||||
|
REM
|
||||||
|
REM Le script :
|
||||||
|
REM 1. Lit version\proserve-X.Y.Z\ (binaires UE5 + _migrations\ + _report\)
|
||||||
|
REM 2. Compresse en version\proserve-X.Y.Z.zip
|
||||||
|
REM 3. Affiche taille + SHA-256 (à recopier dans le backoffice si tu veux
|
||||||
|
REM eviter le hash automatique côté serveur, sinon laisse le serveur le
|
||||||
|
REM faire au moment du « Hasher + signer »)
|
||||||
|
REM
|
||||||
|
REM Outils :
|
||||||
|
REM - 7-Zip si installé (multi-threading, ~3-5x plus rapide sur 14 Go)
|
||||||
|
REM - PowerShell Compress-Archive en fallback (mono-thread, lent)
|
||||||
|
REM
|
||||||
|
REM Sortie : version\proserve-X.Y.Z.zip à uploader vers
|
||||||
|
REM www/PS_Launcher/builds/proserve-X.Y.Z.zip via SFTP.
|
||||||
|
REM ========================================================================
|
||||||
|
|
||||||
|
setlocal EnableDelayedExpansion
|
||||||
|
|
||||||
|
if "%~1"=="" (
|
||||||
|
echo.
|
||||||
|
echo Usage : build-proserve-zip.bat ^<version^>
|
||||||
|
echo Exemple : build-proserve-zip.bat 1.5.3
|
||||||
|
echo.
|
||||||
|
echo Versions disponibles dans version\ :
|
||||||
|
for /D %%D in ("%~dp0version\proserve-*") do echo %%~nxD
|
||||||
|
echo.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "VERSION=%~1"
|
||||||
|
set "REPO=%~dp0"
|
||||||
|
set "SRC=%REPO%version\proserve-%VERSION%"
|
||||||
|
set "OUT=%REPO%version\proserve-%VERSION%.zip"
|
||||||
|
|
||||||
|
if not exist "%SRC%" (
|
||||||
|
echo [ERREUR] Dossier source introuvable : %SRC%
|
||||||
|
echo Liste des versions disponibles :
|
||||||
|
for /D %%D in ("%REPO%version\proserve-*") do echo %%~nxD
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Sanity-check que les sous-dossiers attendus sont là
|
||||||
|
if not exist "%SRC%\PROSERVE_UE_5_5.exe" (
|
||||||
|
echo [WARN] %SRC%\PROSERVE_UE_5_5.exe absent — c'est normal ? Continuer dans 5s, Ctrl+C pour annuler...
|
||||||
|
timeout /t 5 >nul
|
||||||
|
)
|
||||||
|
if not exist "%SRC%\_migrations" (
|
||||||
|
echo [WARN] %SRC%\_migrations\ absent — pas de migrations DB dans cette release.
|
||||||
|
)
|
||||||
|
if not exist "%SRC%\_report" (
|
||||||
|
echo [WARN] %SRC%\_report\ absent — pas d'outil Report dans cette release.
|
||||||
|
)
|
||||||
|
|
||||||
|
if exist "%OUT%" (
|
||||||
|
echo [INFO] Suppression du zip existant : %OUT%
|
||||||
|
del /Q "%OUT%"
|
||||||
|
)
|
||||||
|
|
||||||
|
REM ----- 7-Zip si disponible -----
|
||||||
|
set "SEVENZIP="
|
||||||
|
if exist "C:\Program Files\7-Zip\7z.exe" set "SEVENZIP=C:\Program Files\7-Zip\7z.exe"
|
||||||
|
if exist "C:\Program Files (x86)\7-Zip\7z.exe" set "SEVENZIP=C:\Program Files (x86)\7-Zip\7z.exe"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================================================
|
||||||
|
echo Compression de proserve-%VERSION% (peut prendre 5-30 min selon la taille)
|
||||||
|
echo Source : %SRC%
|
||||||
|
echo Sortie : %OUT%
|
||||||
|
echo ========================================================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
if defined SEVENZIP (
|
||||||
|
echo [INFO] Utilisation de 7-Zip multi-thread (rapide)
|
||||||
|
REM -tzip format ZIP standard ^(compatible avec ce que le launcher attend^)
|
||||||
|
REM -mx=5 compression normale ^(mx=9 ultra serait 2x plus lent pour ~5%% de gain^)
|
||||||
|
REM -mmt=on multi-thread
|
||||||
|
REM -bsp1 progress sur stderr
|
||||||
|
REM Le * en fin de chemin source = on zippe le CONTENU du dossier, pas le dossier lui-meme
|
||||||
|
"%SEVENZIP%" a -tzip -mx=5 -mmt=on -bsp1 "%OUT%" "%SRC%\*"
|
||||||
|
set "EXITCODE=!ERRORLEVEL!"
|
||||||
|
) else (
|
||||||
|
echo [INFO] 7-Zip non trouve, fallback sur PowerShell Compress-Archive ^(lent^).
|
||||||
|
echo Pour accelerer, installe 7-Zip depuis https://www.7-zip.org/
|
||||||
|
echo.
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
|
||||||
|
"Compress-Archive -Path '%SRC%\*' -DestinationPath '%OUT%' -CompressionLevel Optimal -Force"
|
||||||
|
set "EXITCODE=!ERRORLEVEL!"
|
||||||
|
)
|
||||||
|
|
||||||
|
if !EXITCODE! NEQ 0 (
|
||||||
|
echo.
|
||||||
|
echo [ECHEC] Compression echouee avec le code !EXITCODE!.
|
||||||
|
exit /b !EXITCODE!
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%OUT%" (
|
||||||
|
echo [ECHEC] Le fichier %OUT% n'a pas ete cree.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================================================
|
||||||
|
echo [OK] Zip cree avec succes
|
||||||
|
echo ========================================================================
|
||||||
|
echo.
|
||||||
|
echo Fichier : %OUT%
|
||||||
|
for %%I in ("%OUT%") do (
|
||||||
|
set "SIZE=%%~zI"
|
||||||
|
echo Taille : !SIZE! octets ^(~%%~zI bytes^)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Calcul du SHA-256 ^(peut prendre 30s-2min selon la taille^)...
|
||||||
|
powershell -NoProfile -Command ^
|
||||||
|
"$h = (Get-FileHash '%OUT%' -Algorithm SHA256).Hash.ToLower(); Write-Host SHA-256 : $h"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================================================
|
||||||
|
echo Prochaines etapes :
|
||||||
|
echo 1. SFTP upload vers : www/PS_Launcher/builds/proserve-%VERSION%.zip
|
||||||
|
echo 2. Backoffice -^> Versions -^> verifier que l'entree v%VERSION% existe
|
||||||
|
echo ^(sinon ajoute-la avec date d'expiration min_license_date^)
|
||||||
|
echo 3. Cliquer ligne v%VERSION% -^> bouton "Hasher cette version"
|
||||||
|
echo ^(ou bouton global "Hasher les versions + signer"^)
|
||||||
|
echo 4. Le manifest est re-signe Ed25519, les clients verront la version
|
||||||
|
echo au prochain "Verifier les MAJ".
|
||||||
|
echo ========================================================================
|
||||||
|
echo.
|
||||||
|
endlocal
|
||||||
|
pause
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM Build de l'updater (PSLauncher.Updater.exe).
|
REM Build de l'updater (PS_Launcher.Updater.exe).
|
||||||
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PSLauncher.Updater.exe (~34 Mo single-file)
|
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PS_Launcher.Updater.exe (~34 Mo single-file)
|
||||||
REM L'updater accompagne PSLauncher.exe — il doit etre dans le meme dossier.
|
REM L'updater accompagne PS_Launcher.exe — il doit etre dans le meme dossier.
|
||||||
|
|
||||||
setlocal
|
setlocal
|
||||||
set "REPO=%~dp0"
|
set "REPO=%~dp0"
|
||||||
echo.
|
echo.
|
||||||
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
|
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
|
||||||
taskkill /F /IM PSLauncher.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
|
||||||
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
|
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo ==^> dotnet publish PSLauncher.Updater (Release single-file)
|
echo ==^> dotnet publish PSLauncher.Updater (Release single-file)
|
||||||
echo.
|
echo.
|
||||||
@@ -20,9 +20,9 @@ echo.
|
|||||||
if %EXITCODE% NEQ 0 (
|
if %EXITCODE% NEQ 0 (
|
||||||
echo [ECHEC] Build de l'updater echoue avec le code %EXITCODE%.
|
echo [ECHEC] Build de l'updater echoue avec le code %EXITCODE%.
|
||||||
) else (
|
) else (
|
||||||
echo [OK] PSLauncher.Updater.exe disponible a la racine du repo :
|
echo [OK] PS_Launcher.Updater.exe disponible a la racine du repo :
|
||||||
echo %REPO%PSLauncher.Updater.exe
|
echo %REPO%PS_Launcher.Updater.exe
|
||||||
)
|
)
|
||||||
echo.
|
echo.
|
||||||
pause
|
pause
|
||||||
endlocal & exit /b %EXITCODE%
|
endlocal ^& exit /b %EXITCODE%
|
||||||
|
|||||||
@@ -4,18 +4,18 @@
|
|||||||
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" PSLauncher.iss
|
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" PSLauncher.iss
|
||||||
;
|
;
|
||||||
; Avant compile :
|
; Avant compile :
|
||||||
; 1. Faire `dotnet publish src/PSLauncher.App -c Release` (génère PSLauncher.exe ~77 Mo)
|
; 1. Faire `dotnet publish src/PSLauncher.App -c Release` (génère PS_Launcher.exe ~77 Mo)
|
||||||
; 2. Faire `dotnet publish src/PSLauncher.Updater -c Release` (génère PSLauncher.Updater.exe ~10 Mo)
|
; 2. Faire `dotnet publish src/PSLauncher.Updater -c Release` (génère PS_Launcher.Updater.exe ~10 Mo)
|
||||||
;
|
;
|
||||||
; Le script picore directement les binaires dans bin/Release/.../publish/.
|
; Le script picore directement les binaires dans bin/Release/.../publish/.
|
||||||
|
|
||||||
#define MyAppName "PROSERVE Launcher"
|
#define MyAppName "PROSERVE Launcher"
|
||||||
#define MyAppShortName "PSLauncher"
|
#define MyAppShortName "PS_Launcher"
|
||||||
#define MyAppVersion "0.8.0"
|
#define MyAppVersion "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,19 +28,23 @@ AppPublisher={#MyAppPublisher}
|
|||||||
AppPublisherURL={#MyAppURL}
|
AppPublisherURL={#MyAppURL}
|
||||||
AppSupportURL={#MyAppURL}
|
AppSupportURL={#MyAppURL}
|
||||||
AppUpdatesURL={#MyAppURL}
|
AppUpdatesURL={#MyAppURL}
|
||||||
; Installation user-mode (pas d'admin requis) : le launcher pourra se mettre à jour
|
; Installation system-wide dans Program Files. {autopf} résout vers
|
||||||
; tout seul via PSLauncher.Updater.exe sans déclencher de UAC.
|
; "C:\Program Files" sur cible 64-bit (cf. ArchitecturesInstallIn64BitMode=x64
|
||||||
DefaultDirName={localappdata}\Programs\{#MyAppPublisher}\{#MyAppShortName}
|
; 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}
|
||||||
DefaultGroupName={#MyAppName}
|
DefaultGroupName={#MyAppName}
|
||||||
DisableProgramGroupPage=yes
|
DisableProgramGroupPage=yes
|
||||||
OutputDir=output
|
OutputDir=output
|
||||||
OutputBaseFilename=PSLauncher-Setup-{#MyAppVersion}
|
OutputBaseFilename=PS_Launcher-Setup-{#MyAppVersion}
|
||||||
SetupIconFile=..\src\PSLauncher.App\Resources\favicon.ico
|
SetupIconFile=..\src\PSLauncher.App\Resources\favicon.ico
|
||||||
Compression=lzma2/ultra
|
Compression=lzma2/ultra
|
||||||
SolidCompression=yes
|
SolidCompression=yes
|
||||||
WizardStyle=modern
|
WizardStyle=modern
|
||||||
PrivilegesRequired=lowest
|
PrivilegesRequired=admin
|
||||||
PrivilegesRequiredOverridesAllowed=dialog
|
|
||||||
UsedUserAreasWarning=no
|
UsedUserAreasWarning=no
|
||||||
ArchitecturesAllowed=x64
|
ArchitecturesAllowed=x64
|
||||||
ArchitecturesInstallIn64BitMode=x64
|
ArchitecturesInstallIn64BitMode=x64
|
||||||
@@ -61,6 +65,14 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription
|
|||||||
Source: "..\src\PSLauncher.App\bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\src\PSLauncher.App\bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
Source: "..\src\PSLauncher.Updater\bin\Release\net8.0-windows\win-x64\publish\{#MyUpdaterExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
Source: "..\src\PSLauncher.Updater\bin\Release\net8.0-windows\win-x64\publish\{#MyUpdaterExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
Source: "..\docs\PS_Launcher-Guide-Utilisateur.pptx"; DestDir: "{app}\docs"; Flags: ignoreversion
|
Source: "..\docs\PS_Launcher-Guide-Utilisateur.pptx"; DestDir: "{app}\docs"; Flags: ignoreversion
|
||||||
|
; WebView2 Evergreen Bootstrapper (Microsoft, redistribuable). 1.6 MB.
|
||||||
|
; Téléchargé depuis https://go.microsoft.com/fwlink/p/?LinkId=2124703
|
||||||
|
; Exécuté en silencieux à l'install SI WebView2 Runtime n'est pas déjà présent
|
||||||
|
; (vérification registre via la fonction Pascal IsWebView2Installed ci-dessous).
|
||||||
|
; Sans ça, sur un Win10/Server sans WebView2 préinstallé, la fenêtre du launcher
|
||||||
|
; reste toute blanche au démarrage parce que les contrôles WebView2 (Reports +
|
||||||
|
; Documentation) ne peuvent pas s'initialiser et plantent en silence le rendu UI.
|
||||||
|
Source: "redists\MicrosoftEdgeWebview2Setup.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall; Check: not IsWebView2Installed
|
||||||
|
|
||||||
[Icons]
|
[Icons]
|
||||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||||
@@ -69,11 +81,55 @@ 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.
@@ -18,4 +18,7 @@ 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
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();
|
||||||
@@ -42,10 +42,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
if ($action === 'set_launcher') {
|
if ($action === 'set_launcher') {
|
||||||
$lver = trim($_POST['launcher_version'] ?? '');
|
$lver = trim($_POST['launcher_version'] ?? '');
|
||||||
$lmin = trim($_POST['launcher_min_required'] ?? '');
|
$lmin = trim($_POST['launcher_min_required'] ?? '');
|
||||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) {
|
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
|
||||||
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).');
|
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lver)) {
|
||||||
|
throw new Exception('Numéro de version launcher invalide (X.Y.Z ou X.Y.Z.B attendu).');
|
||||||
}
|
}
|
||||||
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+$/', $lmin)) {
|
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lmin)) {
|
||||||
throw new Exception('Numéro de version "minRequired" invalide.');
|
throw new Exception('Numéro de version "minRequired" invalide.');
|
||||||
}
|
}
|
||||||
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
|
||||||
@@ -53,14 +54,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
'version' => $lver,
|
'version' => $lver,
|
||||||
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
|
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
|
||||||
'download' => [
|
'download' => [
|
||||||
'url' => "{$base}/builds/launcher/PSLauncher-{$lver}.exe",
|
'url' => "{$base}/builds/launcher/PS_Launcher-{$lver}.exe",
|
||||||
'sizeBytes' => 0,
|
'sizeBytes' => 0,
|
||||||
'sha256' => 'REPLACE_AFTER_BUILD',
|
'sha256' => 'REPLACE_AFTER_BUILD',
|
||||||
],
|
],
|
||||||
'releaseNotesUrl' => null,
|
'releaseNotesUrl' => null,
|
||||||
];
|
];
|
||||||
saveManifest($manifestPath, $manifest);
|
saveManifest($manifestPath, $manifest);
|
||||||
$message = "Section launcher mise à jour (v{$lver}). Upload PSLauncher-{$lver}.exe dans builds/launcher/ puis clique « Hasher le launcher + signer ».";
|
$message = "Section launcher mise à jour (v{$lver}). Upload PS_Launcher-{$lver}.exe dans builds/launcher/ puis clique « Hasher le launcher + signer ».";
|
||||||
}
|
}
|
||||||
elseif ($action === 'remove_launcher') {
|
elseif ($action === 'remove_launcher') {
|
||||||
unset($manifest['launcher']);
|
unset($manifest['launcher']);
|
||||||
@@ -70,10 +71,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
elseif ($action === 'sync_launcher') {
|
elseif ($action === 'sync_launcher') {
|
||||||
require_once "$root/tools/SignManifest.php";
|
require_once "$root/tools/SignManifest.php";
|
||||||
$signer = new \PSLauncher\Tools\SignManifest($root);
|
$signer = new \PSLauncher\Tools\SignManifest($root);
|
||||||
$result = $signer->run('launcher');
|
$force = !empty($_POST['force']);
|
||||||
$message = "Sortie du script (scope: launcher) :\n" . implode("\n", $result['log']);
|
$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';
|
if (!$result['ok']) $messageType = 'error';
|
||||||
}
|
}
|
||||||
|
// (Le settings lock est maintenant par-license, géré dans licenses.php — voir migration 003.)
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$message = $e->getMessage();
|
$message = $e->getMessage();
|
||||||
$messageType = 'error';
|
$messageType = 'error';
|
||||||
@@ -128,7 +132,7 @@ Layout::header('Launcher', 'launcher');
|
|||||||
<ol class="muted">
|
<ol class="muted">
|
||||||
<li>Remplis le formulaire <strong>Définir / mettre à jour</strong> ci-dessous (ex : <code>0.6.0</code>).</li>
|
<li>Remplis le formulaire <strong>Définir / mettre à jour</strong> ci-dessous (ex : <code>0.6.0</code>).</li>
|
||||||
<li>Sur ton poste : <code>dotnet publish src\PSLauncher.App -c Release</code> et <code>dotnet publish src\PSLauncher.Updater -c Release</code> (les exes sont copiés à la racine du repo).</li>
|
<li>Sur ton poste : <code>dotnet publish src\PSLauncher.App -c Release</code> et <code>dotnet publish src\PSLauncher.Updater -c Release</code> (les exes sont copiés à la racine du repo).</li>
|
||||||
<li>Renomme la copie : <code>PSLauncher.exe → PSLauncher-X.Y.Z.exe</code>.</li>
|
<li>Renomme la copie : <code>PS_Launcher.exe → PS_Launcher-X.Y.Z.exe</code>.</li>
|
||||||
<li>Upload via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
|
<li>Upload via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
|
||||||
<li>Clique <strong>🔁 Hasher le launcher + signer</strong>.</li>
|
<li>Clique <strong>🔁 Hasher le launcher + signer</strong>.</li>
|
||||||
<li>Les launchers déjà déployés détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
|
<li>Les launchers déjà déployés détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
|
||||||
@@ -142,14 +146,14 @@ Layout::header('Launcher', 'launcher');
|
|||||||
<input type="hidden" name="action" value="set_launcher">
|
<input type="hidden" name="action" value="set_launcher">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col field">
|
<div class="col field">
|
||||||
<label>Version launcher (X.Y.Z)</label>
|
<label>Version launcher (X.Y.Z ou X.Y.Z.B)</label>
|
||||||
<input type="text" name="launcher_version" placeholder="0.6.0"
|
<input type="text" name="launcher_version" placeholder="0.6.0"
|
||||||
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+">
|
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+(\.\d+)?">
|
||||||
</div>
|
</div>
|
||||||
<div class="col field">
|
<div class="col field">
|
||||||
<label>Version minimale requise (X.Y.Z)</label>
|
<label>Version minimale requise (X.Y.Z ou X.Y.Z.B)</label>
|
||||||
<input type="text" name="launcher_min_required" placeholder="0.5.0"
|
<input type="text" name="launcher_min_required" placeholder="0.5.0"
|
||||||
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+">
|
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+(\.\d+)?">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-success" type="submit">Définir</button>
|
<button class="btn btn-success" type="submit">Définir</button>
|
||||||
@@ -159,6 +163,9 @@ Layout::header('Launcher', 'launcher');
|
|||||||
<?= Layout::csrfField() ?>
|
<?= Layout::csrfField() ?>
|
||||||
<input type="hidden" name="action" value="sync_launcher">
|
<input type="hidden" name="action" value="sync_launcher">
|
||||||
<button class="btn btn-primary" type="submit">🔁 Hasher le launcher + signer</button>
|
<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>
|
</form>
|
||||||
|
|
||||||
<?php if ($launcher): ?>
|
<?php if ($launcher): ?>
|
||||||
|
|||||||
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,7 @@ 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'],
|
'launcher' => ['launcher.php', 'Launcher'],
|
||||||
'audit' => ['audit.php', 'Audit'],
|
'audit' => ['audit.php', 'Audit'],
|
||||||
];
|
];
|
||||||
|
|||||||
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,7 +19,8 @@ final class DownloadUrl
|
|||||||
{
|
{
|
||||||
public static function handle(array $config, string $version): void
|
public static function handle(array $config, string $version): void
|
||||||
{
|
{
|
||||||
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
|
// Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
|
||||||
|
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $version)) {
|
||||||
Response::error('invalid_version', 'Version invalide', 400);
|
Response::error('invalid_version', 'Version invalide', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,12 +75,36 @@ final class DownloadUrl
|
|||||||
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
|
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
|
||||||
}
|
}
|
||||||
$manifest = json_decode(file_get_contents($manifestPath), true);
|
$manifest = json_decode(file_get_contents($manifestPath), true);
|
||||||
|
|
||||||
|
// Filename attendu, envoyé par le client (extrait de son propre manifest signé).
|
||||||
|
// Sert à disambiguer les manifestes multi-channels où plusieurs entrées
|
||||||
|
// partagent le même numéro de version (ex : proserve-firefighter-1.5.4.32 et
|
||||||
|
// proserve-full-1.5.4.32 sur v1.5.4.32). Sans ce filtre, le foreach ci-dessous
|
||||||
|
// retournait la 1re entrée matchant le numéro → l'URL signée pointait vers le
|
||||||
|
// MAUVAIS ZIP, et le client détectait le mismatch filename manifest vs signé
|
||||||
|
// et abortait (garde-fou côté MainViewModel.InstallVersionAsync). Optionnel
|
||||||
|
// pour rétro-compat avec les vieux clients (v1.0.4-) qui ne l'envoient pas ;
|
||||||
|
// dans ce cas la 1re entrée gagne, comme avant.
|
||||||
|
$expectedFilename = trim((string)($_GET['filename'] ?? ''));
|
||||||
|
// Whitelist défensive — même règle que la validation du filename lu du manifest
|
||||||
|
// plus bas dans cette route. Bloque path traversal via query param.
|
||||||
|
if ($expectedFilename !== '' && !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $expectedFilename)) {
|
||||||
|
Response::error('invalid_filename', "Query filename invalide : '{$expectedFilename}'", 400);
|
||||||
|
}
|
||||||
|
|
||||||
$entry = null;
|
$entry = null;
|
||||||
foreach ($manifest['versions'] ?? [] as $v) {
|
foreach ($manifest['versions'] ?? [] as $v) {
|
||||||
if (($v['version'] ?? '') === $version) { $entry = $v; break; }
|
if (($v['version'] ?? '') !== $version) continue;
|
||||||
|
if ($expectedFilename !== '') {
|
||||||
|
$entryFilename = basename(parse_url((string)($v['download']['url'] ?? ''), PHP_URL_PATH) ?: '');
|
||||||
|
if ($entryFilename !== $expectedFilename) continue;
|
||||||
|
}
|
||||||
|
$entry = $v;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (!$entry) {
|
if (!$entry) {
|
||||||
Response::error('version_not_found', "Version {$version} absente du manifest", 404);
|
$suffix = $expectedFilename !== '' ? " (filename attendu : {$expectedFilename})" : '';
|
||||||
|
Response::error('version_not_found', "Version {$version} absente du manifest{$suffix}", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérif droits téléchargement
|
// Vérif droits téléchargement
|
||||||
@@ -95,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,26 +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; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le cache des hashs précalculés. Retourne un dictionnaire
|
||||||
|
* [path => ['size' => int, 'mtime' => int, 'sha256' => string]].
|
||||||
|
* @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.
|
* 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 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(string $scope = 'all'): 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];
|
||||||
@@ -46,10 +203,31 @@ final class SignManifest
|
|||||||
|
|
||||||
$doVersions = ($scope === 'all' || $scope === 'versions');
|
$doVersions = ($scope === 'all' || $scope === 'versions');
|
||||||
$doLauncher = ($scope === 'all' || $scope === 'launcher');
|
$doLauncher = ($scope === 'all' || $scope === 'launcher');
|
||||||
|
if ($onlyVersion !== null || $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 = [];
|
||||||
if ($doVersions) 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");
|
||||||
@@ -74,12 +252,64 @@ 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);
|
||||||
@@ -99,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/");
|
||||||
}
|
}
|
||||||
@@ -111,14 +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
|
||||||
// (uniquement si on a touché aux versions)
|
// hashAlgorithm=none) parmi TOUTES les entrées du manifest, pas seulement celles
|
||||||
if ($doVersions && !empty($hashedVersions)) {
|
// qu'on a re-hashé sur ce run. Évite de redescendre `latest` quand on hash une
|
||||||
usort($hashedVersions, 'version_compare');
|
// ancienne version isolée via $onlyVersion.
|
||||||
$newLatest = end($hashedVersions);
|
if ($doVersions) {
|
||||||
if (($manifest['latest'] ?? null) !== $newLatest) {
|
$eligible = [];
|
||||||
$this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}");
|
foreach ($manifest['versions'] as $vEntry) {
|
||||||
$manifest['latest'] = $newLatest;
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,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);
|
||||||
|
|||||||
@@ -14,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,6 +35,13 @@ namespace PSLauncher.App;
|
|||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
private IHost? _host;
|
private IHost? _host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IServiceProvider du host DI. Exposé pour les rares cas où une vue est
|
||||||
|
/// instanciée par <c>new</c> hors du container (ex: SettingsDialog) et a
|
||||||
|
/// besoin de résoudre un service.
|
||||||
|
/// </summary>
|
||||||
|
public IServiceProvider? HostServices => _host?.Services;
|
||||||
private static System.Threading.Mutex? _singleInstanceMutex;
|
private static System.Threading.Mutex? _singleInstanceMutex;
|
||||||
|
|
||||||
private const string SingleInstanceMutexName = "Global\\PSLauncher-3F8E2C1A-9B47-4D5E-A0F8-2E9D1B6C7A3F";
|
private const string SingleInstanceMutexName = "Global\\PSLauncher-3F8E2C1A-9B47-4D5E-A0F8-2E9D1B6C7A3F";
|
||||||
@@ -63,6 +80,33 @@ public partial class App : Application
|
|||||||
"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()
|
||||||
@@ -76,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");
|
||||||
@@ -93,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;
|
||||||
@@ -115,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>();
|
||||||
@@ -131,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,15 +9,18 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<!-- Le générateur WinRT (CsWinRT v2.x, déclenché par WebView2/UWP refs sur SDK récent)
|
||||||
|
émet du code unsafe dans WinRTGenericInstantiation.g.cs. Sans ce flag, build = CS0227. -->
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
<ApplicationIcon>Resources\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>Resources\favicon.ico</ApplicationIcon>
|
||||||
<AssemblyName>PSLauncher</AssemblyName>
|
<AssemblyName>PS_Launcher</AssemblyName>
|
||||||
<Company>ASTERION VR</Company>
|
<Company>ASTERION VR</Company>
|
||||||
<Product>PROSERVE Launcher</Product>
|
<Product>PROSERVE Launcher</Product>
|
||||||
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
|
||||||
<RootNamespace>PSLauncher.App</RootNamespace>
|
<RootNamespace>PSLauncher.App</RootNamespace>
|
||||||
<Version>0.8.0</Version>
|
<Version>1.0.13</Version>
|
||||||
<AssemblyVersion>0.8.0.0</AssemblyVersion>
|
<AssemblyVersion>1.0.13.0</AssemblyVersion>
|
||||||
<FileVersion>0.8.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>
|
||||||
@@ -40,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>
|
||||||
@@ -55,7 +63,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Après chaque `dotnet publish -c Release`, copie le single-file exe à la racine du repo
|
<!-- Après chaque `dotnet publish -c Release`, copie le single-file exe à la racine du repo
|
||||||
(C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe). ContinueOnError pour ne pas casser le
|
(C:\ASTERION\GIT\PS_Launcher\PS_Launcher.exe). ContinueOnError pour ne pas casser le
|
||||||
publish si une instance du launcher tourne et verrouille la cible — l'opérateur peut
|
publish si une instance du launcher tourne et verrouille la cible — l'opérateur peut
|
||||||
killer et réessayer, ou simplement utiliser le binaire depuis bin\Release\... -->
|
killer et réessayer, ou simplement utiliser le binaire depuis bin\Release\... -->
|
||||||
<Target Name="CopyPublishedToRepoRoot" AfterTargets="Publish"
|
<Target Name="CopyPublishedToRepoRoot" AfterTargets="Publish"
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace PSLauncher.App.Resources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inverse de <see cref="System.Windows.Controls.BooleanToVisibilityConverter"/> : true → Collapsed, false → Visible.
|
||||||
|
/// Utile pour afficher un placeholder quand un binding bool est false (ex. "DocumentationUrl est vide").
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InverseBoolToVisibilityConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is bool b && b ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is Visibility v && v != Visibility.Visible;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns:res="clr-namespace:PSLauncher.App.Resources">
|
xmlns:res="clr-namespace:PSLauncher.App.Resources">
|
||||||
|
|
||||||
<BooleanToVisibilityConverter x:Key="BoolToVisibility" />
|
<BooleanToVisibilityConverter x:Key="BoolToVisibility" />
|
||||||
|
<res:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisibility" />
|
||||||
|
|
||||||
<!-- Custom font Pirulen pour les titres de marque -->
|
<!-- Custom font Pirulen pour les titres de marque -->
|
||||||
<FontFamily x:Key="Font.Brand">pack://application:,,,/Resources/#Pirulen</FontFamily>
|
<FontFamily x:Key="Font.Brand">pack://application:,,,/Resources/#Pirulen</FontFamily>
|
||||||
@@ -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" />
|
||||||
@@ -206,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. -->
|
||||||
|
|||||||
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,8 @@
|
|||||||
<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"
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
Width="500" Height="320"
|
Width="500" Height="320"
|
||||||
ResizeMode="NoResize"
|
ResizeMode="NoResize"
|
||||||
@@ -16,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}" />
|
||||||
|
|
||||||
@@ -33,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>
|
||||||
@@ -41,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,9 +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"
|
||||||
Icon="pack://application:,,,/Resources/favicon.ico"
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
Width="500" Height="480"
|
Width="540" Height="540"
|
||||||
|
SizeToContent="Height"
|
||||||
|
MinHeight="480"
|
||||||
ResizeMode="NoResize"
|
ResizeMode="NoResize"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
Background="{StaticResource Brush.Bg.Window}">
|
Background="{StaticResource Brush.Bg.Window}">
|
||||||
@@ -15,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 -->
|
||||||
@@ -45,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"
|
||||||
@@ -56,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"
|
||||||
@@ -67,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}" />
|
||||||
@@ -77,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">
|
||||||
@@ -91,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>
|
||||||
@@ -101,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,13 +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"
|
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"
|
||||||
@@ -54,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>
|
||||||
@@ -81,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>
|
||||||
@@ -111,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"
|
||||||
@@ -142,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>
|
||||||
@@ -159,19 +222,44 @@
|
|||||||
</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 : limité au body uniquement (Grid.Row="1") pour ne pas
|
<!-- Background image : limité à la zone content (Row 2, Col 1) pour ne pas
|
||||||
se faire chevaucher par le footer quand un download tourne. ImageBrush
|
passer derrière la sidebar, le banner ou le footer. -->
|
||||||
avec AlignmentX=Right + AlignmentY=Bottom : si le ratio fenêtre force
|
<Rectangle Grid.Row="2" Grid.Column="1" IsHitTestVisible="False">
|
||||||
un crop, on rogne en haut/à gauche — le logo ASTERION VR en bas-droite
|
|
||||||
reste TOUJOURS visible. -->
|
|
||||||
<Rectangle Grid.Row="1" IsHitTestVisible="False">
|
|
||||||
<Rectangle.Fill>
|
<Rectangle.Fill>
|
||||||
<ImageBrush ImageSource="pack://application:,,,/Resources/Background.png"
|
<ImageBrush ImageSource="pack://application:,,,/Resources/Background.png"
|
||||||
Stretch="UniformToFill"
|
Stretch="UniformToFill"
|
||||||
@@ -180,9 +268,13 @@
|
|||||||
Opacity="0.25" />
|
Opacity="0.25" />
|
||||||
</Rectangle.Fill>
|
</Rectangle.Fill>
|
||||||
</Rectangle>
|
</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>
|
||||||
@@ -218,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" />
|
||||||
@@ -282,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">
|
||||||
|
|
||||||
@@ -333,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>
|
||||||
@@ -364,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>
|
||||||
@@ -399,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"
|
||||||
@@ -450,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>
|
||||||
@@ -473,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" />
|
||||||
@@ -504,25 +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}" />
|
||||||
|
|
||||||
<!-- Copyright flottant en bas centré, sur une pill sombre pour rester
|
<!-- Copyright flottant en bas centré, sur une pill sombre pour rester
|
||||||
lisible quel que soit l'arrière-plan (image, voile noir, etc.). -->
|
lisible quel que soit l'arrière-plan (image, voile noir, etc.).
|
||||||
|
Bottom margin volontairement faible pour rester collé au footer
|
||||||
|
(barre de DL) quand il s'affiche. -->
|
||||||
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
|
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
|
||||||
Margin="0,0,0,28"
|
Margin="0,0,0,8"
|
||||||
Background="#A0000000"
|
Background="#A0000000"
|
||||||
CornerRadius="10"
|
CornerRadius="10"
|
||||||
Padding="10,4">
|
Padding="10,4">
|
||||||
<TextBlock Text="© 2026 ASTERION VR — Tous droits réservés"
|
<TextBlock Text="{x:Static loc:Strings.Copyright}"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Foreground="White" />
|
Foreground="White" />
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<!-- ====================== END LIBRARY PAGE ====================== -->
|
||||||
|
|
||||||
<!-- Footer : visible uniquement quand busy / status à afficher.
|
<!-- ====================== REPORT PAGE (WebView2) ====================== -->
|
||||||
Le bouton "Vérifier les MAJ" a été déplacé en flottant au-dessus du body. -->
|
<Grid Background="{StaticResource Brush.Bg.Window}"
|
||||||
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
|
Visibility="{Binding IsReport, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<wv2:WebView2 x:Name="ReportWebView"
|
||||||
|
Source="{Binding ReportUri, Mode=OneWay}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ====================== DOCUMENTATION PAGE ====================== -->
|
||||||
|
<Grid Background="{StaticResource Brush.Bg.Window}"
|
||||||
|
Visibility="{Binding IsDocumentation, Converter={StaticResource BoolToVisibility}}">
|
||||||
|
<!-- WebView2 si une URL est configurée, sinon un placeholder explicatif -->
|
||||||
|
<wv2:WebView2 x:Name="DocsWebView"
|
||||||
|
Source="{Binding DocumentationUri, Mode=OneWay}"
|
||||||
|
Visibility="{Binding HasDocumentationUrl, Converter={StaticResource BoolToVisibility}}" />
|
||||||
|
<TextBlock Text="{x:Static loc:Strings.DocsPlaceholder}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Foreground="{StaticResource Brush.Text.Secondary}"
|
||||||
|
FontSize="14" TextAlignment="Center" TextWrapping="Wrap"
|
||||||
|
MaxWidth="500"
|
||||||
|
Visibility="{Binding HasDocumentationUrl, Converter={StaticResource InverseBoolToVisibility}}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
<!-- ============== END Content area ============== -->
|
||||||
|
|
||||||
|
<!-- ============== Left sidebar : nav buttons ==============
|
||||||
|
Row 1 + 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}">
|
||||||
@@ -542,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>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ 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 System.Windows.Interop;
|
||||||
|
using Microsoft.Web.WebView2.Wpf;
|
||||||
|
using PSLauncher.App.ViewModels;
|
||||||
|
using PSLauncher.Core.Localization;
|
||||||
|
|
||||||
namespace PSLauncher.App.Views;
|
namespace PSLauncher.App.Views;
|
||||||
|
|
||||||
@@ -23,10 +26,107 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
private const int SW_RESTORE = 9;
|
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;
|
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>
|
/// <summary>
|
||||||
@@ -52,6 +152,12 @@ public partial class MainWindow : Window
|
|||||||
SetForegroundWindow(hwnd);
|
SetForegroundWindow(hwnd);
|
||||||
handled = true;
|
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;
|
return IntPtr.Zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 @@
|
|||||||
<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"
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
Width="540" Height="420"
|
Width="540" Height="420"
|
||||||
MinWidth="480" MinHeight="380"
|
MinWidth="480" MinHeight="380"
|
||||||
@@ -18,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"
|
||||||
@@ -52,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,8 @@
|
|||||||
<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"
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
Width="640" Height="540"
|
Width="640" Height="540"
|
||||||
MinWidth="480" MinHeight="400"
|
MinWidth="480" MinHeight="400"
|
||||||
@@ -33,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,8 @@
|
|||||||
<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"
|
Icon="pack://application:,,,/Resources/favicon.ico"
|
||||||
Width="640" Height="540"
|
Width="640" Height="540"
|
||||||
MinWidth="480" MinHeight="400"
|
MinWidth="480" MinHeight="400"
|
||||||
@@ -13,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"
|
||||||
@@ -36,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);
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
62
src/PSLauncher.Core/Lan/IPeerSourceResolver.cs
Normal file
62
src/PSLauncher.Core/Lan/IPeerSourceResolver.cs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
namespace PSLauncher.Core.Lan;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL d'un peer LAN qui a déjà la version demandée et a passé la vérification
|
||||||
|
/// (taille + SHA-256 du manifest match la réponse <c>/v1/has/</c>). À utiliser
|
||||||
|
/// dans le DownloadJob comme URL initiale au lieu de l'URL OVH.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PeerSource(Uri ZipUrl, string PeerHost);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper interne aux probers (PeerSourceResolver, PeerManifestFetcher) pour
|
||||||
|
/// fusionner peers actifs (beacons UDP récents), peers connus (cache disque),
|
||||||
|
/// peers manuels (config). Évite la duplication entre les 2 fetchers.
|
||||||
|
/// </summary>
|
||||||
|
internal static class PeerCandidates
|
||||||
|
{
|
||||||
|
public static List<string> Build(ILanDiscoveryService discovery, IEnumerable<string> manualUrls)
|
||||||
|
{
|
||||||
|
var candidates = new List<string>();
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// 1) Peers actifs (UDP beacon < 60s) — les plus fiables
|
||||||
|
foreach (var p in discovery.ListDiscovered())
|
||||||
|
{
|
||||||
|
var url = $"http://{p.Host}:{p.Port}";
|
||||||
|
if (seen.Add(url)) candidates.Add(url);
|
||||||
|
}
|
||||||
|
// 2) Peers connus (cache disque, runs précédents) — essayés rapidement même
|
||||||
|
// si pas encore reçu de beacon ce run (cold start, < 15s d'uptime)
|
||||||
|
foreach (var p in discovery.ListKnown())
|
||||||
|
{
|
||||||
|
var url = $"http://{p.Host}:{p.Port}";
|
||||||
|
if (seen.Add(url)) candidates.Add(url);
|
||||||
|
}
|
||||||
|
// 3) Peers manuels (config user) — fallback / override explicite
|
||||||
|
foreach (var url in manualUrls)
|
||||||
|
{
|
||||||
|
var trimmed = url.Trim().TrimEnd('/');
|
||||||
|
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||||
|
if (seen.Add(trimmed)) candidates.Add(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trouve un peer LAN qui a la version demandée. Combine peers découverts auto
|
||||||
|
/// (UDP discovery) et peers manuels (config), probe chacun avec un timeout court,
|
||||||
|
/// retourne le premier qui matche le SHA-256 attendu. Null si aucun → fallback OVH.
|
||||||
|
///
|
||||||
|
/// Budget total : <c>ProbeTimeoutMs</c> × max 5 peers = ~5 s pire cas. Ne ralentit
|
||||||
|
/// jamais le path OVH au-delà.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPeerSourceResolver
|
||||||
|
{
|
||||||
|
Task<PeerSource?> TryResolveAsync(
|
||||||
|
string version,
|
||||||
|
long expectedSize,
|
||||||
|
string expectedSha256,
|
||||||
|
CancellationToken ct);
|
||||||
|
}
|
||||||
64
src/PSLauncher.Core/Lan/IZipCacheStore.cs
Normal file
64
src/PSLauncher.Core/Lan/IZipCacheStore.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
namespace PSLauncher.Core.Lan;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Une entrée du cache local des ZIPs PROSERVE déjà téléchargés. Le SHA-256 est
|
||||||
|
/// stocké dans un fichier sidecar <c>.sha256</c> à côté du ZIP — calculé une seule
|
||||||
|
/// fois au moment du Promote (déjà connu via le manifest signé), pas re-hashé à
|
||||||
|
/// chaque ListCached() (un md5 sur 14 Go prendrait 30s-3min).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CachedZip(string Version, string Path, long SizeBytes, string Sha256, DateTime ModifiedUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cache local des ZIPs PROSERVE. Sert deux usages :
|
||||||
|
/// 1. Permet à un PC en mode serveur LAN de servir les ZIPs aux peers du LAN.
|
||||||
|
/// 2. Permet le rollback ou la réinstall instantanée d'une version sans re-DL.
|
||||||
|
///
|
||||||
|
/// Stockage : <c>%LocalAppData%\PSLauncher\downloads\proserve-{version}.zip</c>
|
||||||
|
/// (mêmechemin que les .partial du DownloadStateStore, le ZIP final y atterrit
|
||||||
|
/// déjà après VerifyAndFinalizeAsync). Le sidecar <c>.sha256</c> contient le
|
||||||
|
/// hash en hex pour servir les peers sans re-calcul.
|
||||||
|
///
|
||||||
|
/// Pruning : conserve les <c>keepLatest</c> plus récents par mtime, MAIS ne
|
||||||
|
/// supprime jamais un ZIP dont la version est encore installée (cf.
|
||||||
|
/// <see cref="PSLauncher.Core.Installations.IInstallationRegistry.Scan"/>).
|
||||||
|
/// </summary>
|
||||||
|
public interface IZipCacheStore
|
||||||
|
{
|
||||||
|
/// <summary>Chemin canonique du ZIP pour cette version (qu'il existe ou non).</summary>
|
||||||
|
string GetCachedZipPath(string version);
|
||||||
|
|
||||||
|
/// <summary>Vrai si on a un ZIP + sidecar SHA-256 valide pour cette version.</summary>
|
||||||
|
bool TryGetCachedEntry(string version, out CachedZip entry);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Promeut un ZIP final (post-VerifyAndFinalizeAsync) au statut "caché".
|
||||||
|
/// Si <paramref name="srcPath"/> est déjà au bon endroit (= GetCachedZipPath),
|
||||||
|
/// no-op sur le fichier — écrit juste le sidecar SHA. Sinon, déplace.
|
||||||
|
/// </summary>
|
||||||
|
Task PromoteAsync(string srcPath, string version, string sha256, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supprime les ZIPs hors top-N par mtime. Skip silencieusement les versions
|
||||||
|
/// encore installées localement (peu importe leur âge).
|
||||||
|
/// </summary>
|
||||||
|
Task PruneAsync(int keepLatest, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Liste tous les ZIPs cachés valides (avec sidecar SHA présent).</summary>
|
||||||
|
IReadOnlyList<CachedZip> ListCached();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supprime AGRESSIVEMENT le ZIP + sidecar SHA d'une version donnée du cache
|
||||||
|
/// local, peu importe son âge ou si la version est encore installée. Utilisé
|
||||||
|
/// dans deux cas :
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Échec de vérif SHA après DL : on évacue l'éventuel ZIP cache de
|
||||||
|
/// la version concernée pour ne pas servir du contenu obsolète aux peers
|
||||||
|
/// LAN (et pour forcer un re-DL complet au prochain Install).</item>
|
||||||
|
/// <item>Action manuelle "Forcer re-téléchargement" depuis le menu UI :
|
||||||
|
/// l'opérateur veut purger explicitement (typiquement quand la source
|
||||||
|
/// OVH est en cache CDN stale et qu'il a besoin d'un re-DL frais).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Best-effort : log les erreurs I/O mais ne throw pas.
|
||||||
|
/// </summary>
|
||||||
|
Task InvalidateAsync(string version, CancellationToken ct);
|
||||||
|
}
|
||||||
466
src/PSLauncher.Core/Lan/LanCacheServer.cs
Normal file
466
src/PSLauncher.Core/Lan/LanCacheServer.cs
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Core.Manifests;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Lan;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serveur HTTP local pour le partage de ZIPs LAN, basé sur <see cref="TcpListener"/>.
|
||||||
|
///
|
||||||
|
/// Pourquoi pas <c>HttpListener</c> ? Parce que <c>HttpListener.Start()</c> exige une
|
||||||
|
/// URL ACL admin pour TOUT prefix qui n'est pas <c>localhost</c> — y compris quand on
|
||||||
|
/// bind sur une IP spécifique (j'avais cru le contraire et c'était faux). Avec
|
||||||
|
/// <c>TcpListener</c>, n'importe quel utilisateur peut bind sur <c>0.0.0.0:port</c>
|
||||||
|
/// sans URL ACL, sans installer le moindre handler système. On parse HTTP à la main
|
||||||
|
/// (request line + headers + Range), c'est ~80 LOC et le protocole est trivial pour
|
||||||
|
/// nos 2 routes en GET.
|
||||||
|
///
|
||||||
|
/// Sécurité (inchangée) :
|
||||||
|
/// - Filtre RFC1918 / link-local sur RemoteEndPoint avant tout File.IO
|
||||||
|
/// - Validation regex stricte du path version (anti-path-traversal)
|
||||||
|
/// - SemaphoreSlim(4) pour rate-limit
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LanCacheServer : BackgroundService, ILanCacheServer
|
||||||
|
{
|
||||||
|
private const int MaxConcurrentServes = 4;
|
||||||
|
private const int CopyBufferSize = 1 << 16; // 64 KiB
|
||||||
|
private const int HeaderReadTimeoutMs = 5_000;
|
||||||
|
private static readonly Regex VersionRegex = new(@"^[0-9A-Za-z._-]+$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private readonly Func<LanCacheConfig> _configProvider;
|
||||||
|
private readonly IZipCacheStore _cache;
|
||||||
|
private readonly IManifestCache _manifestCache;
|
||||||
|
private readonly ILogger<LanCacheServer> _logger;
|
||||||
|
private readonly SemaphoreSlim _serveLimit = new(MaxConcurrentServes, MaxConcurrentServes);
|
||||||
|
|
||||||
|
private TcpListener? _listener;
|
||||||
|
private List<string> _boundEndpoints = new();
|
||||||
|
private int? _boundPort;
|
||||||
|
|
||||||
|
public LanCacheServer(
|
||||||
|
Func<LanCacheConfig> configProvider,
|
||||||
|
IZipCacheStore cache,
|
||||||
|
IManifestCache manifestCache,
|
||||||
|
ILogger<LanCacheServer> logger)
|
||||||
|
{
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_cache = cache;
|
||||||
|
_manifestCache = manifestCache;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRunning => _listener?.Server.IsBound == true;
|
||||||
|
public int? BoundPort => _boundPort;
|
||||||
|
public IReadOnlyList<string> BoundEndpoints => _boundEndpoints;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
if (!cfg.ServerEnabled)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("LAN cache server disabled (ServerEnabled=false)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listener = new TcpListener(IPAddress.Any, cfg.ServerPort);
|
||||||
|
_listener.Start();
|
||||||
|
_boundPort = cfg.ServerPort;
|
||||||
|
|
||||||
|
// Liste informative (affichée dans Settings UI). Le bind réel est
|
||||||
|
// sur 0.0.0.0 donc toutes les interfaces, mais on n'affiche que les
|
||||||
|
// IPs LAN — c'est elles que les peers vont contacter.
|
||||||
|
_boundEndpoints = GetLocalLanIPv4()
|
||||||
|
.Select(ip => $"http://{ip}:{cfg.ServerPort}")
|
||||||
|
.ToList();
|
||||||
|
if (_boundEndpoints.Count == 0)
|
||||||
|
_boundEndpoints.Add($"http://0.0.0.0:{cfg.ServerPort}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to start LAN cache server on port {Port}. Le port est-il déjà utilisé ?", cfg.ServerPort);
|
||||||
|
try { _listener?.Stop(); } catch { }
|
||||||
|
_listener = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("LAN cache server listening on TCP :{Port} ({Endpoints})",
|
||||||
|
cfg.ServerPort, string.Join(", ", _boundEndpoints));
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
TcpClient client;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
client = await _listener.AcceptTcpClientAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (ObjectDisposedException) { break; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "AcceptTcpClient failed (will retry)");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une task par connexion, fire-and-forget.
|
||||||
|
_ = Task.Run(() => HandleConnectionAsync(client, stoppingToken), stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { _listener.Stop(); } catch { }
|
||||||
|
_listener = null;
|
||||||
|
_logger.LogInformation("LAN cache server stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleConnectionAsync(TcpClient client, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var remoteIp = (client.Client.RemoteEndPoint as IPEndPoint)?.Address;
|
||||||
|
using (client)
|
||||||
|
await using (var stream = client.GetStream())
|
||||||
|
{
|
||||||
|
// 1) Filtre RFC1918 sur l'IP source. Defense in depth.
|
||||||
|
if (remoteIp is null || !IsRfc1918OrLinkLocal(remoteIp))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Rejecting non-LAN request from {Remote}", remoteIp);
|
||||||
|
await WriteSimpleResponseAsync(stream, 403, "forbidden: LAN-only", ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Lit la request line + headers (timeout 5s pour une connexion qui
|
||||||
|
// se connecte sans rien envoyer — anti-slow-loris basique).
|
||||||
|
using var headerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
headerCts.CancelAfter(HeaderReadTimeoutMs);
|
||||||
|
var request = await ReadRequestAsync(stream, headerCts.Token).ConfigureAwait(false);
|
||||||
|
if (request is null)
|
||||||
|
{
|
||||||
|
await WriteSimpleResponseAsync(stream, 400, "bad request", ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Routing : seul GET, sur /v1/has/{ver} ou /v1/zip/{ver}
|
||||||
|
if (!string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await WriteSimpleResponseAsync(stream, 405, "method not allowed", ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string hasPrefix = "/v1/has/";
|
||||||
|
const string zipPrefix = "/v1/zip/";
|
||||||
|
const string manifestPath = "/v1/manifest";
|
||||||
|
const string infoPath = "/v1/info";
|
||||||
|
if (request.Path.StartsWith(hasPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var version = request.Path.Substring(hasPrefix.Length);
|
||||||
|
await HandleHasAsync(stream, version, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (request.Path.StartsWith(zipPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var version = request.Path.Substring(zipPrefix.Length);
|
||||||
|
await HandleZipAsync(stream, version, request.RangeHeader, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (request.Path == manifestPath)
|
||||||
|
{
|
||||||
|
await HandleManifestAsync(stream, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (request.Path == infoPath)
|
||||||
|
{
|
||||||
|
await HandleInfoAsync(stream, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await WriteSimpleResponseAsync(stream, 404, "not found", ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Error handling LAN cache connection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleHasAsync(NetworkStream stream, string version, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!VersionRegex.IsMatch(version))
|
||||||
|
{
|
||||||
|
await WriteSimpleResponseAsync(stream, 400, "invalid version", ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_cache.TryGetCachedEntry(version, out var entry))
|
||||||
|
{
|
||||||
|
await WriteSimpleResponseAsync(stream, 404, "not in cache", ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
sha256 = entry.Sha256,
|
||||||
|
size = entry.SizeBytes,
|
||||||
|
mtime = entry.ModifiedUtc,
|
||||||
|
});
|
||||||
|
var body = Encoding.UTF8.GetBytes(json);
|
||||||
|
await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Endpoint léger d'auto-discovery active. Retourne la même structure que
|
||||||
|
/// le beacon UDP : <c>{v, host, port, versions}</c>. Sert au cold-start
|
||||||
|
/// d'un client qui veut probe ses peers connus du cache disque ou manuels
|
||||||
|
/// SANS attendre les 15 s du prochain beacon UDP.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleInfoAsync(NetworkStream stream, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var versions = _cache.ListCached().Select(c => c.Version).ToList();
|
||||||
|
var json = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
v = 1,
|
||||||
|
host = (_boundEndpoints.Count > 0 ? _boundEndpoints[0] : $"http://0.0.0.0:{_boundPort}")
|
||||||
|
.Replace("http://", "").Split(':')[0],
|
||||||
|
port = _boundPort ?? 0,
|
||||||
|
versions,
|
||||||
|
});
|
||||||
|
var body = Encoding.UTF8.GetBytes(json);
|
||||||
|
await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sert le manifest cached byte-for-byte aux peers du LAN qui n'ont pas
|
||||||
|
/// d'accès Internet. Le client validera la signature Ed25519 du manifest
|
||||||
|
/// (trust chain inchangée). 404 si pas de cache (le serveur PC n'a jamais
|
||||||
|
/// fetché OVH avec succès).
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleManifestAsync(NetworkStream stream, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var path = _manifestCache.GetCachedFilePath();
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
await WriteSimpleResponseAsync(stream, 404, "no manifest cached", ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
|
||||||
|
await WriteResponseAsync(stream, 200, "OK", "application/json", bytes.LongLength, null, bytes, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to serve cached manifest");
|
||||||
|
await WriteSimpleResponseAsync(stream, 500, "manifest read error", ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleZipAsync(NetworkStream stream, string version, string? rangeHeader, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!VersionRegex.IsMatch(version))
|
||||||
|
{
|
||||||
|
await WriteSimpleResponseAsync(stream, 400, "invalid version", ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_cache.TryGetCachedEntry(version, out var entry))
|
||||||
|
{
|
||||||
|
await WriteSimpleResponseAsync(stream, 404, "not in cache", ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _serveLimit.WaitAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await WriteSimpleResponseAsync(stream, 503, "server busy, try later", ct).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var fs = new FileStream(entry.Path, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||||
|
CopyBufferSize, useAsync: true);
|
||||||
|
|
||||||
|
long total = fs.Length;
|
||||||
|
long start = 0;
|
||||||
|
long end = total - 1;
|
||||||
|
bool isPartial = false;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var spec = rangeHeader.Substring("bytes=".Length);
|
||||||
|
var parts = spec.Split('-', 2);
|
||||||
|
if (parts.Length == 2 && long.TryParse(parts[0], out var s) && s >= 0 && s < total)
|
||||||
|
{
|
||||||
|
start = s;
|
||||||
|
if (parts[1].Length > 0 && long.TryParse(parts[1], out var e) && e >= start && e < total)
|
||||||
|
end = e;
|
||||||
|
isPartial = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var headers = $"Content-Range: bytes */{total}\r\n";
|
||||||
|
await WriteSimpleResponseAsync(stream, 416, "range not satisfiable", ct, headers).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var length = end - start + 1;
|
||||||
|
int code = isPartial ? 206 : 200;
|
||||||
|
string status = isPartial ? "Partial Content" : "OK";
|
||||||
|
string? extraHeaders = null;
|
||||||
|
if (isPartial) extraHeaders = $"Content-Range: bytes {start}-{end}/{total}\r\n";
|
||||||
|
|
||||||
|
// Headers : Content-Type + Length + Accept-Ranges + ETag + (Content-Range si 206) + Connection: close
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("HTTP/1.1 ").Append(code).Append(' ').Append(status).Append("\r\n");
|
||||||
|
sb.Append("Content-Type: application/zip\r\n");
|
||||||
|
sb.Append("Content-Length: ").Append(length).Append("\r\n");
|
||||||
|
sb.Append("Accept-Ranges: bytes\r\n");
|
||||||
|
sb.Append("ETag: \"").Append(entry.Sha256).Append("\"\r\n");
|
||||||
|
if (extraHeaders is not null) sb.Append(extraHeaders);
|
||||||
|
sb.Append("Connection: close\r\n\r\n");
|
||||||
|
var headerBytes = Encoding.ASCII.GetBytes(sb.ToString());
|
||||||
|
await stream.WriteAsync(headerBytes, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Body : stream le fichier en chunks
|
||||||
|
fs.Seek(start, SeekOrigin.Begin);
|
||||||
|
var buffer = new byte[CopyBufferSize];
|
||||||
|
long remaining = length;
|
||||||
|
while (remaining > 0)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
int toRead = (int)Math.Min(buffer.Length, remaining);
|
||||||
|
int n = await fs.ReadAsync(buffer.AsMemory(0, toRead), ct).ConfigureAwait(false);
|
||||||
|
if (n <= 0) break;
|
||||||
|
await stream.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
||||||
|
remaining -= n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_serveLimit.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser HTTP minimal : juste la request line + le header Range (le seul qu'on
|
||||||
|
/// utilise). Les autres headers sont lus mais ignorés. Limite : 8 KiB de headers
|
||||||
|
/// max, suffisamment pour notre cas (typique 200-500 octets).
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<ParsedRequest?> ReadRequestAsync(NetworkStream stream, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var buffer = new byte[8192];
|
||||||
|
int total = 0;
|
||||||
|
int headerEnd = -1;
|
||||||
|
while (total < buffer.Length)
|
||||||
|
{
|
||||||
|
int n = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), ct).ConfigureAwait(false);
|
||||||
|
if (n <= 0) break;
|
||||||
|
total += n;
|
||||||
|
// Cherche \r\n\r\n (fin des headers HTTP)
|
||||||
|
for (int i = 3; i < total; i++)
|
||||||
|
{
|
||||||
|
if (buffer[i - 3] == (byte)'\r' && buffer[i - 2] == (byte)'\n'
|
||||||
|
&& buffer[i - 1] == (byte)'\r' && buffer[i] == (byte)'\n')
|
||||||
|
{
|
||||||
|
headerEnd = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (headerEnd >= 0) break;
|
||||||
|
}
|
||||||
|
if (headerEnd < 0) return null;
|
||||||
|
|
||||||
|
var raw = Encoding.ASCII.GetString(buffer, 0, headerEnd);
|
||||||
|
var lines = raw.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (lines.Length == 0) return null;
|
||||||
|
|
||||||
|
// Ligne 1 : "GET /path HTTP/1.1"
|
||||||
|
var first = lines[0].Split(' ', 3);
|
||||||
|
if (first.Length < 3) return null;
|
||||||
|
var method = first[0];
|
||||||
|
var path = first[1];
|
||||||
|
|
||||||
|
string? rangeHeader = null;
|
||||||
|
for (int i = 1; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
var line = lines[i];
|
||||||
|
var colon = line.IndexOf(':');
|
||||||
|
if (colon <= 0) continue;
|
||||||
|
var name = line.Substring(0, colon).Trim();
|
||||||
|
var value = line.Substring(colon + 1).Trim();
|
||||||
|
if (string.Equals(name, "Range", StringComparison.OrdinalIgnoreCase))
|
||||||
|
rangeHeader = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ParsedRequest(method, path, rangeHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ParsedRequest(string Method, string Path, string? RangeHeader);
|
||||||
|
|
||||||
|
private static Task WriteSimpleResponseAsync(NetworkStream stream, int code, string text, CancellationToken ct, string? extraHeaders = null)
|
||||||
|
{
|
||||||
|
var body = Encoding.UTF8.GetBytes(text);
|
||||||
|
return WriteResponseAsync(stream, code, ReasonPhrase(code), "text/plain; charset=utf-8", body.LongLength, extraHeaders, body, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteResponseAsync(NetworkStream stream, int code, string status, string contentType, long contentLength, string? extraHeaders, byte[]? body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("HTTP/1.1 ").Append(code).Append(' ').Append(status).Append("\r\n");
|
||||||
|
sb.Append("Content-Type: ").Append(contentType).Append("\r\n");
|
||||||
|
sb.Append("Content-Length: ").Append(contentLength).Append("\r\n");
|
||||||
|
if (extraHeaders is not null) sb.Append(extraHeaders);
|
||||||
|
sb.Append("Connection: close\r\n\r\n");
|
||||||
|
var headerBytes = Encoding.ASCII.GetBytes(sb.ToString());
|
||||||
|
await stream.WriteAsync(headerBytes, ct).ConfigureAwait(false);
|
||||||
|
if (body is not null && body.Length > 0)
|
||||||
|
await stream.WriteAsync(body, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReasonPhrase(int code) => code switch
|
||||||
|
{
|
||||||
|
200 => "OK",
|
||||||
|
206 => "Partial Content",
|
||||||
|
400 => "Bad Request",
|
||||||
|
403 => "Forbidden",
|
||||||
|
404 => "Not Found",
|
||||||
|
405 => "Method Not Allowed",
|
||||||
|
416 => "Range Not Satisfiable",
|
||||||
|
500 => "Internal Server Error",
|
||||||
|
503 => "Service Unavailable",
|
||||||
|
_ => "Status",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filtre RFC1918 + link-local (169.254.x.x) + loopback. Pas de bypass
|
||||||
|
/// possible : c'est ce qui garantit qu'aucun ZIP ne sortira accidentellement
|
||||||
|
/// du LAN même si la conf firewall est désactivée.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsRfc1918OrLinkLocal(IPAddress addr)
|
||||||
|
{
|
||||||
|
if (IPAddress.IsLoopback(addr)) return true;
|
||||||
|
if (addr.AddressFamily != AddressFamily.InterNetwork) return false;
|
||||||
|
var b = addr.GetAddressBytes();
|
||||||
|
if (b[0] == 10) return true;
|
||||||
|
if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true;
|
||||||
|
if (b[0] == 192 && b[1] == 168) return true;
|
||||||
|
if (b[0] == 169 && b[1] == 254) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<IPAddress> GetLocalLanIPv4()
|
||||||
|
{
|
||||||
|
return NetworkInterface.GetAllNetworkInterfaces()
|
||||||
|
.Where(n => n.OperationalStatus == OperationalStatus.Up
|
||||||
|
&& n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||||
|
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
|
||||||
|
.Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork
|
||||||
|
&& IsRfc1918OrLinkLocal(a.Address))
|
||||||
|
.Select(a => a.Address);
|
||||||
|
}
|
||||||
|
}
|
||||||
367
src/PSLauncher.Core/Lan/LanDiscoveryService.cs
Normal file
367
src/PSLauncher.Core/Lan/LanDiscoveryService.cs
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Core.Configuration;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Lan;
|
||||||
|
|
||||||
|
public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryService
|
||||||
|
{
|
||||||
|
private const int BeaconIntervalMs = 15_000;
|
||||||
|
private const int PeerTtlSeconds = 60;
|
||||||
|
/// <summary>TTL des peers persistés sur disque entre deux runs du launcher.</summary>
|
||||||
|
private const int KnownPeerStaleHours = 168; // 1 semaine
|
||||||
|
private const string KnownPeersFileName = "lan-peers.json";
|
||||||
|
|
||||||
|
private readonly Func<LanCacheConfig> _configProvider;
|
||||||
|
private readonly IZipCacheStore _cache;
|
||||||
|
private readonly IConfigStore _configStore;
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly ILogger<LanDiscoveryService> _logger;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, DiscoveredPeer> _peers = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
/// <summary>Peers pré-chargés depuis le disque au démarrage. Pas de TTL court — ils
|
||||||
|
/// servent de "seed" pour ne pas attendre les 15s du premier beacon UDP.
|
||||||
|
/// PeerSourceResolver / PeerManifestFetcher les utilisent en plus de ListDiscovered().</summary>
|
||||||
|
private List<DiscoveredPeer> _knownPeers = new();
|
||||||
|
private string? _ourLanIp;
|
||||||
|
private DateTime _lastDiskFlush = DateTime.MinValue;
|
||||||
|
|
||||||
|
public LanDiscoveryService(
|
||||||
|
Func<LanCacheConfig> configProvider,
|
||||||
|
IZipCacheStore cache,
|
||||||
|
IConfigStore configStore,
|
||||||
|
HttpClient http,
|
||||||
|
ILogger<LanDiscoveryService> logger)
|
||||||
|
{
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_cache = cache;
|
||||||
|
_configStore = configStore;
|
||||||
|
_http = http;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<DiscoveredPeer> ListDiscovered()
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.UtcNow.AddSeconds(-PeerTtlSeconds);
|
||||||
|
return _peers.Values.Where(p => p.LastSeenUtc >= cutoff).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Peers connus de runs précédents (persistés sur disque, max 1 semaine).
|
||||||
|
/// Sert de seed pour démarrer rapidement avant que le premier beacon arrive.
|
||||||
|
/// Probés en HTTP par PeerSourceResolver — si le peer est offline on aura juste
|
||||||
|
/// un timeout puis on essaiera le suivant.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<DiscoveredPeer> ListKnown() => _knownPeers;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
if (!cfg.ServerEnabled && !cfg.ClientEnabled)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("LAN discovery disabled (both ServerEnabled and ClientEnabled are false)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ourLanIp = GetLocalLanIp();
|
||||||
|
|
||||||
|
// Charge les peers connus de la session précédente. Les probes pourront
|
||||||
|
// ainsi essayer ces IPs immédiatement, sans attendre les 15s du premier
|
||||||
|
// beacon UDP. Si un peer est offline, on aura juste un timeout HTTP rapide.
|
||||||
|
_knownPeers = LoadKnownPeers();
|
||||||
|
if (_knownPeers.Count > 0)
|
||||||
|
_logger.LogInformation("Loaded {Count} known peers from disk cache", _knownPeers.Count);
|
||||||
|
|
||||||
|
// BOOTSTRAP ACTIF : kick off un probe HTTP /v1/info en parallèle sur tous les
|
||||||
|
// peers connus (cache disque) + manuels (config). Découverte instantanée au
|
||||||
|
// lieu d'attendre les 15 s du prochain beacon UDP. Fire-and-forget : le reste
|
||||||
|
// du service démarre normalement pendant que le probe tourne en background.
|
||||||
|
_ = Task.Run(() => BootstrapProbeKnownPeersAsync(cfg, stoppingToken), stoppingToken);
|
||||||
|
|
||||||
|
// Listener (toujours actif si Client OU Server activé)
|
||||||
|
UdpClient? listener = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
listener = new UdpClient(AddressFamily.InterNetwork);
|
||||||
|
listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
|
||||||
|
listener.Client.Bind(new IPEndPoint(IPAddress.Any, cfg.DiscoveryPort));
|
||||||
|
listener.EnableBroadcast = true;
|
||||||
|
_logger.LogInformation("LAN discovery listening on UDP :{Port}", cfg.DiscoveryPort);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to bind UDP discovery socket on port {Port}", cfg.DiscoveryPort);
|
||||||
|
listener?.Dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var listenTask = ListenLoopAsync(listener, stoppingToken);
|
||||||
|
|
||||||
|
// Broadcaster (uniquement si Server activé)
|
||||||
|
Task? broadcastTask = null;
|
||||||
|
if (cfg.ServerEnabled)
|
||||||
|
{
|
||||||
|
broadcastTask = BroadcastLoopAsync(cfg.DiscoveryPort, cfg.ServerPort, stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(broadcastTask is null ? new[] { listenTask } : new[] { listenTask, broadcastTask })
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* expected on shutdown */ }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { listener.Close(); } catch { }
|
||||||
|
listener.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ListenLoopAsync(UdpClient listener, CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await listener.ReceiveAsync(ct).ConfigureAwait(false);
|
||||||
|
var senderHost = result.RemoteEndPoint.Address.ToString();
|
||||||
|
|
||||||
|
// Ignore nos propres beacons
|
||||||
|
if (_ourLanIp is not null && string.Equals(senderHost, _ourLanIp, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var json = Encoding.UTF8.GetString(result.Buffer);
|
||||||
|
var beacon = JsonSerializer.Deserialize<BeaconPayload>(json);
|
||||||
|
if (beacon is null || beacon.V != 1) continue;
|
||||||
|
if (string.IsNullOrEmpty(beacon.Host) || beacon.Port <= 0 || beacon.Port > 65535) continue;
|
||||||
|
|
||||||
|
var peer = new DiscoveredPeer(
|
||||||
|
Host: beacon.Host,
|
||||||
|
Port: beacon.Port,
|
||||||
|
Versions: (IReadOnlyList<string>?)beacon.Versions ?? Array.Empty<string>(),
|
||||||
|
LastSeenUtc: DateTime.UtcNow);
|
||||||
|
_peers[beacon.Host] = peer;
|
||||||
|
|
||||||
|
// Persist to disk au plus toutes les 30s pour ne pas spammer le SSD
|
||||||
|
// (un beacon arrive toutes les 15s par peer × N peers = beaucoup d'écritures).
|
||||||
|
if ((DateTime.UtcNow - _lastDiskFlush).TotalSeconds >= 30)
|
||||||
|
{
|
||||||
|
_lastDiskFlush = DateTime.UtcNow;
|
||||||
|
SaveKnownPeers(_peers.Values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { break; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Discovery listen iteration failed (will retry)");
|
||||||
|
try { await Task.Delay(500, ct); } catch { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BroadcastLoopAsync(int discoveryPort, int httpPort, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var sender = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true };
|
||||||
|
var endpoint = new IPEndPoint(IPAddress.Broadcast, discoveryPort);
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var versions = _cache.ListCached().Select(c => c.Version).ToList();
|
||||||
|
var host = _ourLanIp ?? GetLocalLanIp() ?? "0.0.0.0";
|
||||||
|
var payload = new BeaconPayload
|
||||||
|
{
|
||||||
|
V = 1,
|
||||||
|
Host = host,
|
||||||
|
Port = httpPort,
|
||||||
|
Versions = versions,
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(payload);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
await sender.SendAsync(bytes, bytes.Length, endpoint).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Discovery broadcast failed (will retry)");
|
||||||
|
}
|
||||||
|
try { await Task.Delay(BeaconIntervalMs, ct); } catch { break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renvoie l'IPv4 LAN du PC : interface UP non-loopback avec une default
|
||||||
|
/// gateway IPv4. Filtre Hyper-V virtual switch, VPN sans gateway, etc.
|
||||||
|
/// Same logic that MainViewModel.LocalIpAddress uses for the sidebar info.
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetLocalLanIp()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return NetworkInterface.GetAllNetworkInterfaces()
|
||||||
|
.Where(n => n.OperationalStatus == OperationalStatus.Up
|
||||||
|
&& n.NetworkInterfaceType != NetworkInterfaceType.Loopback
|
||||||
|
&& n.GetIPProperties().GatewayAddresses
|
||||||
|
.Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork))
|
||||||
|
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
|
||||||
|
.Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork)
|
||||||
|
.Select(a => a.Address.ToString())
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BeaconPayload
|
||||||
|
{
|
||||||
|
[JsonPropertyName("v")] public int V { get; set; }
|
||||||
|
[JsonPropertyName("host")] public string Host { get; set; } = "";
|
||||||
|
[JsonPropertyName("port")] public int Port { get; set; }
|
||||||
|
[JsonPropertyName("versions")] public List<string>? Versions { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StoredPeer
|
||||||
|
{
|
||||||
|
[JsonPropertyName("host")] public string Host { get; set; } = "";
|
||||||
|
[JsonPropertyName("port")] public int Port { get; set; }
|
||||||
|
[JsonPropertyName("versions")] public List<string> Versions { get; set; } = new();
|
||||||
|
[JsonPropertyName("lastSeenUtc")] public DateTime LastSeenUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetKnownPeersPath() =>
|
||||||
|
Path.Combine(_configStore.ConfigDirectory, KnownPeersFileName);
|
||||||
|
|
||||||
|
private List<DiscoveredPeer> LoadKnownPeers()
|
||||||
|
{
|
||||||
|
var path = GetKnownPeersPath();
|
||||||
|
if (!File.Exists(path)) return new List<DiscoveredPeer>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var raw = File.ReadAllText(path);
|
||||||
|
var stored = JsonSerializer.Deserialize<List<StoredPeer>>(raw);
|
||||||
|
if (stored is null) return new List<DiscoveredPeer>();
|
||||||
|
var staleAfter = DateTime.UtcNow.AddHours(-KnownPeerStaleHours);
|
||||||
|
return stored
|
||||||
|
.Where(s => s.LastSeenUtc >= staleAfter
|
||||||
|
&& !string.IsNullOrEmpty(s.Host)
|
||||||
|
&& s.Port > 0 && s.Port < 65536)
|
||||||
|
.Select(s => new DiscoveredPeer(s.Host, s.Port, s.Versions, s.LastSeenUtc))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to load known peers cache (non-fatal)");
|
||||||
|
return new List<DiscoveredPeer>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Probe HTTP actif en parallèle de tous les peers connus (cache disque) et
|
||||||
|
/// manuels (config). Pour chaque peer qui répond à /v1/info, on l'injecte dans
|
||||||
|
/// _peers avec lastSeen=now → il apparaît immédiatement dans ListDiscovered()
|
||||||
|
/// et donc dans la sidebar + PeerSourceResolver. Aucune attente d'un beacon UDP.
|
||||||
|
/// Timeout court (1 s) par peer, parallel pour ne pas additionner.
|
||||||
|
/// </summary>
|
||||||
|
private async Task BootstrapProbeKnownPeersAsync(LanCacheConfig cfg, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var urls = new List<string>();
|
||||||
|
foreach (var p in _knownPeers)
|
||||||
|
{
|
||||||
|
var u = $"http://{p.Host}:{p.Port}";
|
||||||
|
if (seen.Add(u)) urls.Add(u);
|
||||||
|
}
|
||||||
|
foreach (var raw in cfg.ManualPeerUrls ?? new List<string>())
|
||||||
|
{
|
||||||
|
var t = raw.Trim().TrimEnd('/');
|
||||||
|
if (string.IsNullOrEmpty(t)) continue;
|
||||||
|
if (seen.Add(t)) urls.Add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urls.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Bootstrap probe: no known/manual peers to probe");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Bootstrap probe: attempting {Count} known/manual peers in parallel", urls.Count);
|
||||||
|
var tasks = urls.Select(u => ProbeOnePeerAsync(u, ct)).ToArray();
|
||||||
|
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var alive = _peers.Count;
|
||||||
|
_logger.LogInformation("Bootstrap probe done: {Alive} peer(s) responding", alive);
|
||||||
|
if (alive > 0)
|
||||||
|
{
|
||||||
|
// Persist immediately — la session précédente avait peut-être des peers stale,
|
||||||
|
// on remet à jour avec ceux qui répondent vraiment maintenant.
|
||||||
|
SaveKnownPeers(_peers.Values);
|
||||||
|
_lastDiskFlush = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProbeOnePeerAsync(string baseUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
probeCts.CancelAfter(TimeSpan.FromSeconds(1));
|
||||||
|
using var resp = await _http.GetAsync($"{baseUrl}/v1/info", probeCts.Token).ConfigureAwait(false);
|
||||||
|
if (!resp.IsSuccessStatusCode) return;
|
||||||
|
var json = await resp.Content.ReadAsStringAsync(probeCts.Token).ConfigureAwait(false);
|
||||||
|
var beacon = JsonSerializer.Deserialize<BeaconPayload>(json);
|
||||||
|
if (beacon is null || string.IsNullOrEmpty(beacon.Host) || beacon.Port <= 0) return;
|
||||||
|
|
||||||
|
// Ignore notre propre IP (au cas où une URL manuelle pointerait sur nous-mêmes)
|
||||||
|
if (_ourLanIp is not null && string.Equals(beacon.Host, _ourLanIp, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var peer = new DiscoveredPeer(
|
||||||
|
Host: beacon.Host,
|
||||||
|
Port: beacon.Port,
|
||||||
|
Versions: (IReadOnlyList<string>?)beacon.Versions ?? Array.Empty<string>(),
|
||||||
|
LastSeenUtc: DateTime.UtcNow);
|
||||||
|
_peers[beacon.Host] = peer;
|
||||||
|
_logger.LogInformation("Bootstrap probe: peer alive at {Url} (versions: {V})", baseUrl,
|
||||||
|
beacon.Versions is { Count: > 0 } ? string.Join(",", beacon.Versions) : "—");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* shutdown */ }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Bootstrap probe of {Url} failed: {Reason}", baseUrl, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveKnownPeers(IEnumerable<DiscoveredPeer> peers)
|
||||||
|
{
|
||||||
|
var path = GetKnownPeersPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stored = peers
|
||||||
|
.Where(p => !string.IsNullOrEmpty(p.Host))
|
||||||
|
.Select(p => new StoredPeer
|
||||||
|
{
|
||||||
|
Host = p.Host,
|
||||||
|
Port = p.Port,
|
||||||
|
Versions = p.Versions.ToList(),
|
||||||
|
LastSeenUtc = p.LastSeenUtc,
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var json = JsonSerializer.Serialize(stored);
|
||||||
|
// Atomic write : .tmp puis rename pour ne pas corrompre si crash mi-écriture.
|
||||||
|
var tmp = path + ".tmp";
|
||||||
|
File.WriteAllText(tmp, json);
|
||||||
|
File.Move(tmp, path, overwrite: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to save known peers cache (non-fatal)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/PSLauncher.Core/Lan/PeerManifestFetcher.cs
Normal file
75
src/PSLauncher.Core/Lan/PeerManifestFetcher.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Lan;
|
||||||
|
|
||||||
|
public sealed class PeerManifestFetcher : IPeerManifestFetcher
|
||||||
|
{
|
||||||
|
private const int MaxPeersToProbe = 5;
|
||||||
|
|
||||||
|
private readonly Func<LanCacheConfig> _configProvider;
|
||||||
|
private readonly ILanDiscoveryService _discovery;
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly ILogger<PeerManifestFetcher> _logger;
|
||||||
|
|
||||||
|
public PeerManifestFetcher(
|
||||||
|
Func<LanCacheConfig> configProvider,
|
||||||
|
ILanDiscoveryService discovery,
|
||||||
|
HttpClient http,
|
||||||
|
ILogger<PeerManifestFetcher> logger)
|
||||||
|
{
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_discovery = discovery;
|
||||||
|
_http = http;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> TryFetchRawAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
if (!cfg.ClientEnabled) return null;
|
||||||
|
|
||||||
|
// Liste fusionnée : actifs + connus (cache disque) + manuels.
|
||||||
|
var candidates = PeerCandidates.Build(_discovery, cfg.ManualPeerUrls);
|
||||||
|
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No peers available for manifest fallback");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var probeMs = cfg.ProbeTimeoutMs > 0 ? cfg.ProbeTimeoutMs : 1000;
|
||||||
|
foreach (var baseUrl in candidates.Take(MaxPeersToProbe))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
probeCts.CancelAfter(probeMs);
|
||||||
|
|
||||||
|
var url = $"{baseUrl}/v1/manifest";
|
||||||
|
using var resp = await _http.GetAsync(url, probeCts.Token).ConfigureAwait(false);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Peer {Url} manifest fetch returned {Status}", baseUrl, (int)resp.StatusCode);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var json = await resp.Content.ReadAsStringAsync(probeCts.Token).ConfigureAwait(false);
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) continue;
|
||||||
|
_logger.LogInformation("Manifest fetched from peer {Url} ({Bytes} bytes)", baseUrl, json.Length);
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Peer {Url} manifest probe timed out", baseUrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Peer {Url} manifest probe failed", baseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("No peer responded with a manifest");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/PSLauncher.Core/Lan/PeerSourceResolver.cs
Normal file
111
src/PSLauncher.Core/Lan/PeerSourceResolver.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Lan;
|
||||||
|
|
||||||
|
public sealed class PeerSourceResolver : IPeerSourceResolver
|
||||||
|
{
|
||||||
|
private const int MaxPeersToProbe = 5;
|
||||||
|
|
||||||
|
private readonly Func<LanCacheConfig> _configProvider;
|
||||||
|
private readonly ILanDiscoveryService _discovery;
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly ILogger<PeerSourceResolver> _logger;
|
||||||
|
|
||||||
|
public PeerSourceResolver(
|
||||||
|
Func<LanCacheConfig> configProvider,
|
||||||
|
ILanDiscoveryService discovery,
|
||||||
|
HttpClient http,
|
||||||
|
ILogger<PeerSourceResolver> logger)
|
||||||
|
{
|
||||||
|
_configProvider = configProvider;
|
||||||
|
_discovery = discovery;
|
||||||
|
_http = http;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PeerSource?> TryResolveAsync(
|
||||||
|
string version,
|
||||||
|
long expectedSize,
|
||||||
|
string expectedSha256,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = _configProvider();
|
||||||
|
if (!cfg.ClientEnabled)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construit la liste fusionnée : actifs (beacons récents) + connus (cache
|
||||||
|
// disque) + manuels. PeerCandidates dédoublonne et préserve l'ordre.
|
||||||
|
var candidates = PeerCandidates.Build(_discovery, cfg.ManualPeerUrls);
|
||||||
|
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No peers configured/discovered, skipping LAN probe");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var probeMs = cfg.ProbeTimeoutMs > 0 ? cfg.ProbeTimeoutMs : 1000;
|
||||||
|
foreach (var baseUrl in candidates.Take(MaxPeersToProbe))
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
probeCts.CancelAfter(probeMs);
|
||||||
|
|
||||||
|
var hasUrl = $"{baseUrl}/v1/has/{version}";
|
||||||
|
using var resp = await _http.GetAsync(hasUrl, probeCts.Token).ConfigureAwait(false);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Peer {Url} returned {Status} for v{Version}", baseUrl, (int)resp.StatusCode, version);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var info = await resp.Content.ReadFromJsonAsync<HasResponse>(probeCts.Token).ConfigureAwait(false);
|
||||||
|
if (info is null) continue;
|
||||||
|
|
||||||
|
// Vérif size + sha256 contre le manifest signé Ed25519.
|
||||||
|
// Si un peer ment, son ZIP sera rejeté à la vérif SHA finale par
|
||||||
|
// DownloadManager — mais on peut filtrer dès maintenant pour ne
|
||||||
|
// pas perdre 14 Go de DL inutile.
|
||||||
|
if (info.Size != expectedSize)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Peer {Url} reports size {Size} for v{Version}, expected {Expected} — skipping",
|
||||||
|
baseUrl, info.Size, version, expectedSize);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!string.Equals(info.Sha256, expectedSha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Peer {Url} reports SHA mismatch for v{Version} — skipping", baseUrl, version);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var zipUrl = new Uri($"{baseUrl}/v1/zip/{version}");
|
||||||
|
var host = new Uri(baseUrl).Host;
|
||||||
|
_logger.LogInformation("Peer match for v{Version} : {Url}", version, baseUrl);
|
||||||
|
return new PeerSource(zipUrl, host);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Peer {Url} probe timed out for v{Version}", baseUrl, version);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Peer {Url} probe failed for v{Version}", baseUrl, version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("No peer has v{Version}, will fall back to OVH", version);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HasResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("sha256")] public string Sha256 { get; set; } = "";
|
||||||
|
[JsonPropertyName("size")] public long Size { get; set; }
|
||||||
|
[JsonPropertyName("mtime")] public DateTime Mtime { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
212
src/PSLauncher.Core/Lan/ZipCacheStore.cs
Normal file
212
src/PSLauncher.Core/Lan/ZipCacheStore.cs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PSLauncher.Core.Downloads;
|
||||||
|
using PSLauncher.Core.Installations;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Lan;
|
||||||
|
|
||||||
|
public sealed class ZipCacheStore : IZipCacheStore
|
||||||
|
{
|
||||||
|
// Validation stricte du nom de version pour éviter path traversal.
|
||||||
|
// Couvre semver (1.2.3) et variantes (1.2.3-rc1).
|
||||||
|
private static readonly Regex VersionRegex = new(@"^[0-9A-Za-z._-]+$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private const string ZipPrefix = "proserve-";
|
||||||
|
private const string ZipSuffix = ".zip";
|
||||||
|
private const string ShaSuffix = ".zip.sha256";
|
||||||
|
|
||||||
|
private readonly IDownloadStateStore _downloadStore;
|
||||||
|
private readonly IInstallationRegistry _registry;
|
||||||
|
private readonly ILogger<ZipCacheStore> _logger;
|
||||||
|
|
||||||
|
public ZipCacheStore(
|
||||||
|
IDownloadStateStore downloadStore,
|
||||||
|
IInstallationRegistry registry,
|
||||||
|
ILogger<ZipCacheStore> logger)
|
||||||
|
{
|
||||||
|
_downloadStore = downloadStore;
|
||||||
|
_registry = registry;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetCachedZipPath(string version)
|
||||||
|
{
|
||||||
|
if (!VersionRegex.IsMatch(version))
|
||||||
|
throw new ArgumentException($"Invalid version: {version}", nameof(version));
|
||||||
|
return Path.Combine(_downloadStore.GetDownloadsDirectory(), $"{ZipPrefix}{version}{ZipSuffix}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetShaPath(string version) =>
|
||||||
|
Path.Combine(_downloadStore.GetDownloadsDirectory(), $"{ZipPrefix}{version}{ShaSuffix}");
|
||||||
|
|
||||||
|
public bool TryGetCachedEntry(string version, out CachedZip entry)
|
||||||
|
{
|
||||||
|
entry = null!;
|
||||||
|
if (!VersionRegex.IsMatch(version)) return false;
|
||||||
|
var zipPath = GetCachedZipPath(version);
|
||||||
|
var shaPath = GetShaPath(version);
|
||||||
|
if (!File.Exists(zipPath) || !File.Exists(shaPath)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sha = File.ReadAllText(shaPath, Encoding.ASCII).Trim();
|
||||||
|
if (sha.Length != 64) return false; // SHA-256 hex = 64 chars
|
||||||
|
var fi = new FileInfo(zipPath);
|
||||||
|
// Force la lecture des metadata maintenant — si le fichier disparaît
|
||||||
|
// ici (race avec File.Move overwrite=true du DownloadManager), on attrape
|
||||||
|
// FileNotFoundException ci-dessous silencieusement. Sans ce Refresh(),
|
||||||
|
// FileInfo.Length aurait fait l'I/O lazy et throw plus tard.
|
||||||
|
fi.Refresh();
|
||||||
|
entry = new CachedZip(version, zipPath, fi.Length, sha, fi.LastWriteTimeUtc);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
// Race connue et bénigne : un peer client probe /v1/has/{ver} pile au
|
||||||
|
// moment où DownloadManager.VerifyAndFinalizeAsync fait File.Move
|
||||||
|
// (delete-then-rename sur NTFS). Le client retentera ou tombera sur OVH.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Cache lookup failed for {Version}", version);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PromoteAsync(string srcPath, string version, string sha256, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!VersionRegex.IsMatch(version))
|
||||||
|
throw new ArgumentException($"Invalid version: {version}", nameof(version));
|
||||||
|
if (sha256.Length != 64)
|
||||||
|
throw new ArgumentException("SHA-256 hex must be 64 chars", nameof(sha256));
|
||||||
|
|
||||||
|
var dstPath = GetCachedZipPath(version);
|
||||||
|
var shaPath = GetShaPath(version);
|
||||||
|
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
// Si le ZIP est déjà au bon endroit (cas standard post-DownloadManager :
|
||||||
|
// VerifyAndFinalizeAsync a déjà fait File.Move(partial, final)), pas besoin
|
||||||
|
// de bouger. On écrit juste le sidecar SHA.
|
||||||
|
if (!string.Equals(Path.GetFullPath(srcPath), Path.GetFullPath(dstPath), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (File.Exists(dstPath)) File.Delete(dstPath);
|
||||||
|
File.Move(srcPath, dstPath);
|
||||||
|
}
|
||||||
|
File.WriteAllText(shaPath, sha256.ToLowerInvariant(), Encoding.ASCII);
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogInformation("Promoted v{Version} to LAN cache (size={Size} bytes)", version, new FileInfo(dstPath).Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PruneAsync(int keepLatest, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (keepLatest < 0) keepLatest = 0;
|
||||||
|
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Versions encore installées : à protéger inconditionnellement.
|
||||||
|
var installedVersions = new HashSet<string>(
|
||||||
|
_registry.Scan().Select(i => i.Version),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var all = ListCached()
|
||||||
|
.OrderByDescending(c => c.ModifiedUtc)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
int kept = 0;
|
||||||
|
foreach (var entry in all)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
bool isInstalled = installedVersions.Contains(entry.Version);
|
||||||
|
if (kept < keepLatest || isInstalled)
|
||||||
|
{
|
||||||
|
kept++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(entry.Path);
|
||||||
|
var shaPath = GetShaPath(entry.Version);
|
||||||
|
if (File.Exists(shaPath)) File.Delete(shaPath);
|
||||||
|
_logger.LogInformation("Pruned cached ZIP v{Version} ({Size} bytes)", entry.Version, entry.SizeBytes);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not prune cached ZIP {Path} (will retry next time)", entry.Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Cache pruning failed (non-fatal)");
|
||||||
|
}
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvalidateAsync(string version, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!VersionRegex.IsMatch(version))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("InvalidateAsync called with invalid version: {Version}", version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
var zipPath = GetCachedZipPath(version);
|
||||||
|
var shaPath = GetShaPath(version);
|
||||||
|
bool zipDeleted = false, shaDeleted = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(zipPath)) { File.Delete(zipPath); zipDeleted = true; }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not delete cached ZIP for v{Version} at {Path} (file lock?)",
|
||||||
|
version, zipPath);
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(shaPath)) { File.Delete(shaPath); shaDeleted = true; }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not delete sidecar SHA for v{Version} at {Path}",
|
||||||
|
version, shaPath);
|
||||||
|
}
|
||||||
|
if (zipDeleted || shaDeleted)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Invalidated LAN cache for v{Version} (zip={ZipGone}, sidecar={ShaGone})",
|
||||||
|
version, zipDeleted, shaDeleted);
|
||||||
|
}
|
||||||
|
}, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<CachedZip> ListCached()
|
||||||
|
{
|
||||||
|
var dir = _downloadStore.GetDownloadsDirectory();
|
||||||
|
if (!Directory.Exists(dir)) return Array.Empty<CachedZip>();
|
||||||
|
|
||||||
|
var result = new List<CachedZip>();
|
||||||
|
foreach (var zipPath in Directory.EnumerateFiles(dir, $"{ZipPrefix}*{ZipSuffix}", SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
// Skip les .partial (ne matchent pas le pattern de toute façon)
|
||||||
|
var name = Path.GetFileName(zipPath);
|
||||||
|
if (!name.StartsWith(ZipPrefix, StringComparison.Ordinal)) continue;
|
||||||
|
if (!name.EndsWith(ZipSuffix, StringComparison.Ordinal)) continue;
|
||||||
|
var version = name.Substring(ZipPrefix.Length, name.Length - ZipPrefix.Length - ZipSuffix.Length);
|
||||||
|
if (!VersionRegex.IsMatch(version)) continue;
|
||||||
|
if (TryGetCachedEntry(version, out var entry))
|
||||||
|
result.Add(entry);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,19 @@ public interface ILicenseService
|
|||||||
/// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version.
|
/// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version.
|
||||||
/// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
|
/// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
|
||||||
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
|
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
|
||||||
|
/// <paramref name="expectedFilename"/> est le nom du fichier attendu, extrait du
|
||||||
|
/// <c>download.url</c> côté manifest client. Sert au serveur à disambiguer les
|
||||||
|
/// entrées quand plusieurs channels partagent le même numéro de version
|
||||||
|
/// (proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32 sur v1.5.4.32).
|
||||||
|
/// Si null ou vide, le serveur retombe sur "1re entrée matchant le numéro"
|
||||||
|
/// (rétro-compat avec les vieux serveurs qui ignorent ce paramètre).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct);
|
Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null
|
||||||
|
/// si pas de clé stockée ou si le déchiffrement échoue (cache déplacé vers une
|
||||||
|
/// autre machine ou autre user Windows).
|
||||||
|
/// </summary>
|
||||||
|
string? GetDecryptedKey();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,26 @@ using System.Text.Json.Serialization;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NSec.Cryptography;
|
using NSec.Cryptography;
|
||||||
using PSLauncher.Core.Configuration;
|
using PSLauncher.Core.Configuration;
|
||||||
|
using PSLauncher.Core.Security;
|
||||||
using PSLauncher.Models;
|
using PSLauncher.Models;
|
||||||
|
|
||||||
namespace PSLauncher.Core.Licensing;
|
namespace PSLauncher.Core.Licensing;
|
||||||
|
|
||||||
public sealed class LicenseService : ILicenseService
|
public sealed class LicenseService : ILicenseService
|
||||||
{
|
{
|
||||||
private const int CachedValidityDays = 7;
|
/// <summary>
|
||||||
|
/// Durée de validité du cache offline. 100 jours = ~3 mois + marge pour un user
|
||||||
|
/// qui se connecte en moyenne tous les 3 mois. Au-delà, le launcher exige une
|
||||||
|
/// nouvelle validation online (la license elle-même peut être valide sur le serveur,
|
||||||
|
/// c'est juste qu'on ne peut plus le vérifier sans réseau).
|
||||||
|
/// </summary>
|
||||||
|
private const int CachedValidityDays = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tolérance pour les corrections d'horloge légitimes (NTP sync, daylight saving,
|
||||||
|
/// reboot après débranchement long, etc.). Au-delà, on suspecte une manipulation.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan ClockRollbackGrace = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -24,6 +37,7 @@ public sealed class LicenseService : ILicenseService
|
|||||||
private readonly Func<string> _serverBaseUrlProvider;
|
private readonly Func<string> _serverBaseUrlProvider;
|
||||||
private readonly IConfigStore _configStore;
|
private readonly IConfigStore _configStore;
|
||||||
private readonly LocalConfig _config;
|
private readonly LocalConfig _config;
|
||||||
|
private readonly ISettingsLockService _settingsLock;
|
||||||
private readonly ILogger<LicenseService> _logger;
|
private readonly ILogger<LicenseService> _logger;
|
||||||
private readonly string? _serverPublicKeyHex;
|
private readonly string? _serverPublicKeyHex;
|
||||||
|
|
||||||
@@ -32,10 +46,12 @@ public sealed class LicenseService : ILicenseService
|
|||||||
Func<string> serverBaseUrlProvider,
|
Func<string> serverBaseUrlProvider,
|
||||||
IConfigStore configStore,
|
IConfigStore configStore,
|
||||||
LocalConfig config,
|
LocalConfig config,
|
||||||
|
ISettingsLockService settingsLock,
|
||||||
ILogger<LicenseService> logger)
|
ILogger<LicenseService> logger)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_serverBaseUrlProvider = serverBaseUrlProvider;
|
_serverBaseUrlProvider = serverBaseUrlProvider;
|
||||||
|
_settingsLock = settingsLock;
|
||||||
_configStore = configStore;
|
_configStore = configStore;
|
||||||
_config = config;
|
_config = config;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -75,11 +91,19 @@ public sealed class LicenseService : ILicenseService
|
|||||||
|
|
||||||
_logger.LogInformation("Validating license at {Url} (key {KeyHint}…)", url, licenseKey.Length >= 8 ? licenseKey[..8] : licenseKey);
|
_logger.LogInformation("Validating license at {Url} (key {KeyHint}…)", url, licenseKey.Length >= 8 ? licenseKey[..8] : licenseKey);
|
||||||
|
|
||||||
|
// Timeout court (3 s) pour fail-fast en offline. Le HttpClient global a
|
||||||
|
// Timeout=Infinite, donc sans ce CTS on hangerait jusqu'à ce que l'OS
|
||||||
|
// abandonne le SYN TCP (~15-21 s sur Windows). Le call site (Refresh
|
||||||
|
// License pendant Check Updates) catch l'exception et garde le cache
|
||||||
|
// local, donc fail-fast est sans danger.
|
||||||
|
using var validateCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
validateCts.CancelAfter(3_000);
|
||||||
|
|
||||||
using var httpReq = new HttpRequestMessage(HttpMethod.Post, url);
|
using var httpReq = new HttpRequestMessage(HttpMethod.Post, url);
|
||||||
httpReq.Content = JsonContent.Create(req);
|
httpReq.Content = JsonContent.Create(req);
|
||||||
using var resp = await _http.SendAsync(httpReq, ct).ConfigureAwait(false);
|
using var resp = await _http.SendAsync(httpReq, validateCts.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
var bodyText = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
var bodyText = await resp.Content.ReadAsStringAsync(validateCts.Token).ConfigureAwait(false);
|
||||||
LicenseValidationResponse? parsed;
|
LicenseValidationResponse? parsed;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -110,34 +134,123 @@ public sealed class LicenseService : ILicenseService
|
|||||||
_logger.LogDebug("License response signature OK");
|
_logger.LogDebug("License response signature OK");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propage le hash du mot de passe settings lock vers le service in-memory.
|
||||||
|
// L'intégrité est garantie par la signature Ed25519 vérifiée juste au-dessus.
|
||||||
|
_settingsLock.SetPasswordHash(parsed.SettingsLockPasswordHash);
|
||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retourne la license cachée avec deux protections :
|
||||||
|
///
|
||||||
|
/// 1. ANTI-ROLLBACK : si l'horloge système est en retard de plus d'1h par
|
||||||
|
/// rapport au plus récent <c>LastSeenUtc</c> jamais vu par le launcher,
|
||||||
|
/// on suspecte une manipulation (recul de date PC pour gagner du crédit
|
||||||
|
/// offline) → cache invalidé, forçage d'une re-validation online.
|
||||||
|
///
|
||||||
|
/// 2. COMPTEUR MONOTONE : <c>LastSeenUtc</c> ne peut que progresser. Mis à
|
||||||
|
/// jour à <c>max(stored, now)</c> à chaque démarrage et persisté immédiatement.
|
||||||
|
/// Donc même si l'utilisateur recule son horloge APRÈS un démarrage, la
|
||||||
|
/// valeur stockée reste la plus récente jamais vue.
|
||||||
|
///
|
||||||
|
/// Retourne null si :
|
||||||
|
/// - pas de license stockée
|
||||||
|
/// - rollback détecté (horloge manipulée)
|
||||||
|
/// - cache trop ancien (> CachedValidityDays jours depuis LastValidationAt)
|
||||||
|
/// </summary>
|
||||||
public LicenseValidationResponse? GetCached()
|
public LicenseValidationResponse? GetCached()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null;
|
if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null;
|
||||||
if (_config.License.LastValidationAt is null) return null;
|
if (_config.License.LastValidationAt is null) return null;
|
||||||
|
|
||||||
// Cache offline valide 7 jours après la dernière validation réussie
|
var now = DateTime.UtcNow;
|
||||||
var age = DateTime.UtcNow - _config.License.LastValidationAt.Value;
|
var lastValidation = _config.License.LastValidationAt.Value;
|
||||||
if (age.TotalDays > CachedValidityDays) return null;
|
// Si LastSeenUtc absent (1ère exécution avec cette nouvelle version), on initialise
|
||||||
|
// sur LastValidationAt — référence sûre car signée par le serveur.
|
||||||
|
var lastSeen = _config.License.LastSeenUtc ?? lastValidation;
|
||||||
|
|
||||||
return new LicenseValidationResponse
|
// 1. Détection rollback : horloge actuelle significativement en retard
|
||||||
|
if (now < lastSeen - ClockRollbackGrace)
|
||||||
{
|
{
|
||||||
Status = _config.License.CachedEntitlementUntil >= DateTime.UtcNow ? "valid" : "expired",
|
_logger.LogWarning(
|
||||||
|
"Clock rollback detected (now={Now}, lastSeen={LastSeen}, delta={Delta}). " +
|
||||||
|
"Cache invalidated — re-validation requise dès que online.",
|
||||||
|
now, lastSeen, lastSeen - now);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mise à jour monotone : on ne descend jamais
|
||||||
|
var newLastSeen = lastSeen > now ? lastSeen : now;
|
||||||
|
if (_config.License.LastSeenUtc != newLastSeen)
|
||||||
|
{
|
||||||
|
_config.License.LastSeenUtc = newLastSeen;
|
||||||
|
try { _configStore.Save(_config); }
|
||||||
|
catch (Exception ex) { _logger.LogDebug(ex, "Failed to persist LastSeenUtc update"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Âge du cache calculé via le compteur monotone (résistant au rollback).
|
||||||
|
// Si l'utilisateur a fait avancer l'horloge artificiellement, ça consomme
|
||||||
|
// son crédit offline plus vite — pas grave, il ré-valide quand vraiment online.
|
||||||
|
var ageDays = (newLastSeen - lastValidation).TotalDays;
|
||||||
|
if (ageDays > CachedValidityDays)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Offline license cache expired (age {Age:N1} days > max {Max} days). " +
|
||||||
|
"Re-validation requise dès que online.",
|
||||||
|
ageDays, CachedValidityDays);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statut : on prend la valeur cachée par le serveur (révoquée, valide, etc.)
|
||||||
|
// mais avec auto-override "expired" si la date d'entitlement est dépassée.
|
||||||
|
// Ça gère le passage automatique valide → expirée même si le user reste
|
||||||
|
// offline pendant des semaines.
|
||||||
|
var cachedStatus = _config.License.CachedStatus ?? "valid";
|
||||||
|
if (cachedStatus == "valid"
|
||||||
|
&& _config.License.CachedEntitlementUntil is { } entUntil
|
||||||
|
&& entUntil < newLastSeen)
|
||||||
|
{
|
||||||
|
cachedStatus = "expired";
|
||||||
|
}
|
||||||
|
|
||||||
|
var cached = new LicenseValidationResponse
|
||||||
|
{
|
||||||
|
Status = cachedStatus,
|
||||||
OwnerName = _config.License.CachedOwnerName,
|
OwnerName = _config.License.CachedOwnerName,
|
||||||
DownloadEntitlementUntil = _config.License.CachedEntitlementUntil,
|
DownloadEntitlementUntil = _config.License.CachedEntitlementUntil,
|
||||||
ServerTime = _config.License.LastValidationAt,
|
ServerTime = lastValidation,
|
||||||
|
// Restauration des champs channel + canSeeBetas (ajoutés persistants
|
||||||
|
// depuis v0.27.2). Sans ça, le channelProvider du ManifestService
|
||||||
|
// retombait sur null à chaque relance, le client fetch sans
|
||||||
|
// ?channel=, et l'utilisateur ne voyait que les versions default —
|
||||||
|
// jamais les builds spécifiques à son channel (firefighter, police…).
|
||||||
|
Channel = _config.License.CachedChannel,
|
||||||
|
CanSeeBetas = _config.License.CachedCanSeeBetas,
|
||||||
|
SettingsLockPasswordHash = _config.License.CachedSettingsLockPasswordHash,
|
||||||
};
|
};
|
||||||
|
// Propagation au service in-memory : même au démarrage offline, le hash
|
||||||
|
// cache permet de gater les Settings → Avancés tant qu'on n'a pas pu
|
||||||
|
// revalider online.
|
||||||
|
_settingsLock.SetPasswordHash(cached.SettingsLockPasswordHash);
|
||||||
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SaveCached(string licenseKey, LicenseValidationResponse response)
|
public void SaveCached(string licenseKey, LicenseValidationResponse response)
|
||||||
{
|
{
|
||||||
var encrypted = ProtectKey(licenseKey);
|
var encrypted = ProtectKey(licenseKey);
|
||||||
_config.License.EncryptedKey = encrypted;
|
_config.License.EncryptedKey = encrypted;
|
||||||
_config.License.LastValidationAt = DateTime.UtcNow;
|
// ServerTime fait partie du payload signé Ed25519 par le serveur, donc non
|
||||||
_config.License.CachedOwnerName = response.OwnerName;
|
// manipulable par l'utilisateur. Si absent (cas dégradé), on retombe sur
|
||||||
|
// l'horloge locale.
|
||||||
|
_config.License.LastValidationAt = response.ServerTime ?? DateTime.UtcNow;
|
||||||
|
_config.License.LastSeenUtc = DateTime.UtcNow;
|
||||||
|
_config.License.CachedOwnerName = response.OwnerName;
|
||||||
_config.License.CachedEntitlementUntil = response.DownloadEntitlementUntil;
|
_config.License.CachedEntitlementUntil = response.DownloadEntitlementUntil;
|
||||||
|
_config.License.CachedStatus = response.Status;
|
||||||
|
_config.License.CachedChannel = response.Channel;
|
||||||
|
_config.License.CachedCanSeeBetas = response.CanSeeBetas;
|
||||||
|
_config.License.CachedSettingsLockPasswordHash = response.SettingsLockPasswordHash;
|
||||||
_configStore.Save(_config);
|
_configStore.Save(_config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +266,7 @@ public sealed class LicenseService : ILicenseService
|
|||||||
return license.CanDownload(version);
|
return license.CanDownload(version);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct)
|
public async Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var key = GetDecryptedKey();
|
var key = GetDecryptedKey();
|
||||||
if (string.IsNullOrEmpty(key)) return null;
|
if (string.IsNullOrEmpty(key)) return null;
|
||||||
@@ -163,22 +276,36 @@ public sealed class LicenseService : ILicenseService
|
|||||||
// PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
|
// PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
|
||||||
var encodedKey = Uri.EscapeDataString(key);
|
var encodedKey = Uri.EscapeDataString(key);
|
||||||
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
|
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
|
||||||
|
// Passe le filename attendu pour disambiguer les channels sur les manifests
|
||||||
|
// multi-entrée par version. Si null/empty, le serveur retombe sur "1re entrée
|
||||||
|
// matchant le numéro" (rétro-compat avec les vieux serveurs).
|
||||||
|
if (!string.IsNullOrWhiteSpace(expectedFilename))
|
||||||
|
{
|
||||||
|
url += "&filename=" + Uri.EscapeDataString(expectedFilename);
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas
|
||||||
|
// en 5s, on fallback sur l'URL publique du manifest plutôt que de bloquer
|
||||||
|
// la UI 100s. Le DL via peer LAN a déjà été tenté avant ce point — si on
|
||||||
|
// est ici c'est qu'aucun peer n'avait la version.
|
||||||
|
using var sigCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
sigCts.CancelAfter(5_000);
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
using var resp = await _http.SendAsync(req, sigCts.Token).ConfigureAwait(false);
|
||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Signed URL endpoint returned {Status} — falling back to public URL", (int)resp.StatusCode);
|
_logger.LogInformation("Signed URL endpoint returned {Status} — falling back to public URL", (int)resp.StatusCode);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
var body = await resp.Content.ReadAsStringAsync(sigCts.Token).ConfigureAwait(false);
|
||||||
using var doc = JsonDocument.Parse(body);
|
using var doc = JsonDocument.Parse(body);
|
||||||
return doc.RootElement.TryGetProperty("url", out var u) ? u.GetString() : null;
|
return doc.RootElement.TryGetProperty("url", out var u) ? u.GetString() : null;
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Signed URL fetch failed, falling back to public URL");
|
_logger.LogWarning("Signed URL fetch failed/timeout ({Reason}), falling back to public URL", ex.Message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +341,11 @@ public sealed class LicenseService : ILicenseService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static byte[] CanonicalBytesFor(LicenseValidationResponse response)
|
public static byte[] CanonicalBytesFor(LicenseValidationResponse response)
|
||||||
{
|
{
|
||||||
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature
|
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature.
|
||||||
|
// ATTENTION : ordre CRITIQUE — Crypto::canonicalJson côté PHP ne trie pas
|
||||||
|
// les clés, il prend l'ordre du tableau associatif. Toute modif ici doit
|
||||||
|
// être miroir exact du dictionnaire PHP dans ValidateLicense.php.
|
||||||
|
// Version courante (v3, depuis v0.28) : inclut settingsLockPasswordHash.
|
||||||
var dict = new Dictionary<string, object?>
|
var dict = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["status"] = response.Status,
|
["status"] = response.Status,
|
||||||
@@ -223,6 +354,37 @@ public sealed class LicenseService : ILicenseService
|
|||||||
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||||
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||||
["maxMachines"] = response.MaxMachines,
|
["maxMachines"] = response.MaxMachines,
|
||||||
|
["channel"] = response.Channel, // null si default
|
||||||
|
["canSeeBetas"] = response.CanSeeBetas,
|
||||||
|
["settingsLockPasswordHash"] = response.SettingsLockPasswordHash, // null si pas de lock
|
||||||
|
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||||
|
};
|
||||||
|
var opts = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
|
};
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical v2 (v0.26 → v0.27) : sans <c>settingsLockPasswordHash</c>.
|
||||||
|
/// Conservé pour valider les réponses cachées par les anciennes versions
|
||||||
|
/// du client/serveur. Une fois que toutes les licenses auront été re-validées
|
||||||
|
/// online avec le nouveau serveur, on pourra retirer cette méthode.
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] CanonicalBytesForV2(LicenseValidationResponse response)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["status"] = response.Status,
|
||||||
|
["licenseId"] = response.LicenseId,
|
||||||
|
["ownerName"] = response.OwnerName,
|
||||||
|
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||||
|
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||||
|
["maxMachines"] = response.MaxMachines,
|
||||||
|
["channel"] = response.Channel,
|
||||||
|
["canSeeBetas"] = response.CanSeeBetas,
|
||||||
["serverTime"] = FormatDateAtom(response.ServerTime),
|
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||||
};
|
};
|
||||||
var opts = new JsonSerializerOptions
|
var opts = new JsonSerializerOptions
|
||||||
@@ -251,8 +413,22 @@ public sealed class LicenseService : ILicenseService
|
|||||||
var pkBytes = Convert.FromHexString(publicKeyHex);
|
var pkBytes = Convert.FromHexString(publicKeyHex);
|
||||||
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
|
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
|
||||||
var sig = Convert.FromBase64String(base64Signature);
|
var sig = Convert.FromBase64String(base64Signature);
|
||||||
var payload = CanonicalBytesFor(response);
|
|
||||||
return alg.Verify(pk, payload, sig);
|
// Chaîne de fallback canonical pour compat ascendante :
|
||||||
|
// v3 (current) : avec settingsLockPasswordHash (depuis v0.28)
|
||||||
|
// v2 : avec channel + canSeeBetas (v0.26 → v0.27)
|
||||||
|
// legacy : sans aucun de ces champs (avant v0.26)
|
||||||
|
// Sans ces fallbacks, les licenses cachées par d'anciennes versions
|
||||||
|
// deviendraient invalides et l'utilisateur serait forcé de revalider
|
||||||
|
// online — bloqué hors-ligne.
|
||||||
|
var payloadV3 = CanonicalBytesFor(response);
|
||||||
|
if (alg.Verify(pk, payloadV3, sig)) return true;
|
||||||
|
|
||||||
|
var payloadV2 = CanonicalBytesForV2(response);
|
||||||
|
if (alg.Verify(pk, payloadV2, sig)) return true;
|
||||||
|
|
||||||
|
var payloadLegacy = CanonicalBytesForLegacy(response);
|
||||||
|
return alg.Verify(pk, payloadLegacy, sig);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -260,6 +436,33 @@ public sealed class LicenseService : ILicenseService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Canonical historique (avant v0.26.0) : pas de <c>channel</c> ni
|
||||||
|
/// <c>canSeeBetas</c>. Conservé pour valider les réponses cachées par
|
||||||
|
/// les anciennes versions du client. Une fois que toutes les licenses
|
||||||
|
/// auront été re-validées online avec le nouveau serveur, on pourra
|
||||||
|
/// retirer cette méthode (mais on prévient pas de coût à la garder).
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] CanonicalBytesForLegacy(LicenseValidationResponse response)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["status"] = response.Status,
|
||||||
|
["licenseId"] = response.LicenseId,
|
||||||
|
["ownerName"] = response.OwnerName,
|
||||||
|
["issuedAt"] = FormatDateAtom(response.IssuedAt),
|
||||||
|
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
|
||||||
|
["maxMachines"] = response.MaxMachines,
|
||||||
|
["serverTime"] = FormatDateAtom(response.ServerTime),
|
||||||
|
};
|
||||||
|
var opts = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||||
|
};
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Public key embarquée -----
|
// ----- Public key embarquée -----
|
||||||
|
|
||||||
private static string? TryReadEmbeddedPublicKey()
|
private static string? TryReadEmbeddedPublicKey()
|
||||||
|
|||||||
1591
src/PSLauncher.Core/Localization/Strings.cs
Normal file
1591
src/PSLauncher.Core/Localization/Strings.cs
Normal file
File diff suppressed because it is too large
Load Diff
26
src/PSLauncher.Core/Manifests/IManifestCache.cs
Normal file
26
src/PSLauncher.Core/Manifests/IManifestCache.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using PSLauncher.Models;
|
||||||
|
|
||||||
|
namespace PSLauncher.Core.Manifests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cache disque du dernier manifest récupéré avec succès. Sert deux usages :
|
||||||
|
/// 1. Permet au <c>LanCacheServer</c> de servir le manifest aux peers du LAN
|
||||||
|
/// (route <c>GET /v1/manifest</c>).
|
||||||
|
/// 2. Permet au launcher de fallback sur ce cache si OVH est injoignable et
|
||||||
|
/// qu'aucun peer LAN ne répond non plus (mode offline complet).
|
||||||
|
///
|
||||||
|
/// Stocke le JSON brut (pas un objet désérialisé) pour préserver la signature
|
||||||
|
/// Ed25519 byte-for-byte — la canonicalisation côté validation exige les bytes
|
||||||
|
/// originaux. Path : <c>%LocalAppData%\PSLauncher\manifest-cache.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public interface IManifestCache
|
||||||
|
{
|
||||||
|
/// <summary>Persiste le JSON brut sur disque (atomic via .tmp + Move).</summary>
|
||||||
|
Task SaveAsync(string rawJson, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>Chemin du fichier cache (utilisé par LanCacheServer pour servir /v1/manifest).</summary>
|
||||||
|
string GetCachedFilePath();
|
||||||
|
|
||||||
|
/// <summary>Lit + désérialise le manifest cached. Null si pas de cache.</summary>
|
||||||
|
Task<RemoteManifest?> TryLoadAsync(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