Compare commits

...

17 Commits

Author SHA1 Message Date
bebb701d70 v0.23.5 — Retire le check VR du default config
Vive Streaming ment à SteamVR : son driver prétend qu'un HMD est présent
même quand la session Wi-Fi avec le casque physique est tombée. Donc
tous les signaux qui passent par SteamVR (VR_IsHmdPresent, vtable
IVRSystem, logs vrserver) sont contaminés et ne reflètent pas l'état
réel du casque. Le check qu'on a ajouté en v0.23.4 induisait l'utilisateur
en erreur ("vert" alors que le casque est éteint).

Pour l'instant on retire le check du default. Bookmark dans le code des
pistes Phase 2 qui ne passent PAS par SteamVR : connexions TCP/UDP de
HtcConnectionUtility via GetExtendedTcpTable, endpoint local de Vive
Console, compteurs IO, ARP avec OUIs HTC, logs Vive directs.

Le Kind « VrDevice » reste dispo dans l'éditeur pour les setups SteamVR
purs (Index/Vive Wand sur Lighthouse) où le signal est honnête, mais
n'est plus poussé par défaut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:45:55 +02:00
37a4cf424e v0.23.4 — Default config : check VR par défaut en VrDevice (out-of-the-box)
Avant : check « Casque VR » par défaut en Ping avec Target vide (à
remplir par l'utilisateur avec l'IP du casque). Pour Vive Streaming
ça nécessite de connaître l'IP du casque côté Wi-Fi, info pas toujours
exposée par Vive Business Streaming → check désactivé chez 90% des users.

Après : check VrDevice avec Target « hmd ». Marche sans aucune config :
détecte SteamVR + HMD via les flat C OpenVR (VR_IsRuntimeInstalled +
VR_IsHmdPresent + vrserver process check). Pas de SHM/IPC donc pas de
risque AccessViolation comme avec la vtable.

Avantage en plus : répond beaucoup mieux à la question qui compte
(« PROSERVE peut-il se lancer ? ») qu'un ping sur l'IP du casque, qui
indiquait juste la connectivité réseau.

Les utilisateurs déjà installés en v0.23.x gardent leur config existante
(le launcher ne réécrase pas un config.json déjà présent) ; ils peuvent
remplacer leur Ping par un VrDevice via Settings → Bandeau de santé.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:32:06 +02:00
7cb2b01403 v0.23.3 — Mode safe : OpenVR sans vtable (Phase 1 stripped down)
Init OpenVR + binding de la vtable IVRSystem_022 fonctionne (confirmé par
les logs : "OpenVR state: Ready, vtable IVRSystem_022 bindée"), mais le
PREMIER appel vtable crashe en AccessViolation natif. Pas une race au
boot — ça plante même 8s après que vrserver est apparu, et probablement
à n'importe quel moment de la session SteamVR.

Diagnostic : les fonctions de la vtable (IsTrackedDeviceConnected,
GetBoolTrackedDeviceProperty, etc.) accèdent à des shared memories avec
vrserver qui peuvent être (re)mappées dynamiquement quand un device se
connecte/déconnecte. L'AccessViolation natif passe au travers du CLR
via Marshal.GetDelegateForFunctionPointer — non rattrapable par
catch (Exception) ni catch (AccessViolationException).

Fix : on retire TOUS les appels vtable. OpenVrService.Query utilise
maintenant uniquement l'API flat C qui ne fait pas de SHM/IPC :

  - VR_IsRuntimeInstalled() — lookup statique dans la DLL
  - VR_IsHmdPresent() — consulte un mutex/event nommé, pas de SHM
  - GetProcessesByName("vrserver") — process check Windows

Résultat : check VR robuste mais réduit à "SteamVR actif + HMD présent
oui/non". Plus de batterie, plus d'info per-device, le device picker du
dialog devient cosmétique (tous les aliases retournent la même réponse).

La batterie + l'info per-device reviendra en Phase 1.5 via une approche
sub-process : un PSLauncher.VrProbe.exe séparé qui fait les vtable calls
et communique par stdout JSON. Si VrProbe crashe en AccessViolation, le
launcher voit juste "exit code != 0" et report "VR query failed" sans
être affecté lui-même.

Le code de la vtable (delegates, BindVTableDelegates, Init/Shutdown) reste
en place dans OpenVrService.cs comme référence pour Phase 1.5 — n'est
juste plus appelé depuis Query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:16:16 +02:00
07ead6011f v0.23.2 — Fix AccessViolation pendant le démarrage de SteamVR
Cause : race condition. Quand l'utilisateur lance SteamVR alors que le
launcher tourne déjà, vrserver.exe apparaît dans la liste des process
avant que ses drivers et ses shared memories soient prêts à servir des
requêtes vtable. La séquence problématique :

  1. tick health loop → IsVrServerRunning() = true (vrserver vient juste
     d'apparaître dans la process list)
  2. VR_InitInternal2 → succeed (init est tolérant, ne lit pas la SHM)
  3. VR_GetGenericInterface → vtable pointer valide
  4. premier appel vtable (IsTrackedDeviceConnected, slot 21) → lit dans
     une shared memory pas encore allouée par vrserver → AccessViolation
  5. SEH passe au travers du CLR via Marshal.GetDelegateForFunctionPointer
     → process killed sans que catch (Exception) ne se déclenche.

Stack confirmée par l'Event Viewer Windows :
  System.AccessViolationException
    at OpenVrService.Query(System.String)
    at SystemHealthService.CheckVrDevice(...)
    at health loop background task

Fix : cooldown de 8 s après la première détection de vrserver. On note
le timestamp UTC de la première apparition, et on refuse l'init tant que
moins de 8 s n'ont passé. Pendant le warmup, la pill reste sur "SteamVR
non lancé" (HmdAbsent), puis bascule sur Ready au tick suivant. Si
vrserver disparaît, on reset le timestamp + on tear-down la session
proprement, prêts pour un prochain cycle de démarrage.

8 s = compromis : sur SSD le ramp-up des drivers SteamVR prend ~3-5 s,
sur HDD ça peut monter à 7 s. La marge couvre les deux.

Bonus diag : flush Serilog avant que le process meure dans le hook
AppDomain.UnhandledException. Sans ça, la trace de la fatale n'arrivait
jamais sur disque (exactement ce qu'on a vu dans les logs : silence
total avant le redémarrage suivant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:08:32 +02:00
b94c0235b6 OpenVR : log la décision d'état au niveau INFO
Single ligne par transition de state (RuntimeMissing → HmdAbsent → Ready).
Si jamais ça re-crash dans cette zone, on aura la dernière trace avant le
silence de Serilog ; ex. « OpenVR state: HmdAbsent (vrserver.exe pas lancé) ».
Pas de spam : on log uniquement à la transition (un check tourne toutes les
5 s, sans dédup ce serait 700 messages/heure).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:42:26 +02:00
c997859750 v0.23.1 — Fix crash OpenVR quand SteamVR non lancé
Deux bugs corrigés :

1. Marshalling bool incorrect sur VR_IsHmdPresent / VR_IsRuntimeInstalled.
   OpenVR renvoie un bool C++ (1 octet) ; sans MarshalAs(I1) le marshaller
   .NET lit 4 octets (Win32 BOOL) et obtient des valeurs random. Conséquence :
   VR_IsHmdPresent passait parfois à "true" même si aucun casque n'était
   détecté, et on tentait alors une init OpenVR sans backend → crash.

2. Garde process avant init. Même avec le bool marshalling correct,
   VR_IsHmdPresent peut renvoyer true si un HMD est branché en USB sans
   que SteamVR soit lancé. VR_InitInternal2 en mode Background tente alors
   de charger les drivers (lighthouse, oculus_link…) qui peuvent crash en
   SEH si vrserver n'a jamais été démarré sur cette session. On ajoute
   une vérification de la présence de vrserver.exe avant de tenter l'init,
   avec cache 2 s pour ne pas faire un GetProcessesByName à chaque tick.

Résultat : si SteamVR est installé mais pas lancé → pill "SteamVR pas lancé"
proprement, sans tenter d'init et donc sans risque de crash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:38:35 +02:00
4d0e287227 v0.23.0 — SteamVR / OpenVR : check VrDevice avec batterie + tracking
Nouveau Kind « VrDevice » dans le bandeau de santé. Pour chaque appareil
SteamVR (HMD, manettes, trackers, base stations) on lit via OpenVR :
batterie %, état de charge, niveau d'activité (actif / veille / inactif)
et serial number. Severity du pill calculée sur le batterie : < 15% rouge,
< 30% orange, sinon vert ; charging force le vert.

Implémentation : P/Invoke direct dans openvr_api.dll (BSD-3) — pas de
NuGet, pas de bundling de la DLL. La runtime est résolue dynamiquement
via DllImportResolver depuis :
  1. %LocalAppData%\openvr\openvrpaths.vrpath (canon écrit par SteamVR),
  2. fallback C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\.

Si SteamVR n'est pas installé → pill « SteamVR non installé » (Unknown,
gris). Si SteamVR pas lancé → pill « SteamVR non lancé / aucun HMD »
(Error, rouge). Init en mode VRApplication_Background, donc on lit l'état
sans devenir scene application ni monopoliser le compositor.

Éditeur : nouveau radio « Appareil SteamVR » à côté de Process / Ping ;
quand il est sélectionné, le champ Target devient une ComboBox d'alias
(HMD, Manette gauche/droite, Tracker 1-3, Base station 1-2) au lieu du
TextBox libre. Le mapping alias → TrackedDeviceIndex se fait à chaque
query (controllers via GetTrackedDeviceIndexForControllerRole, trackers
et base stations par énumération avec compteur de classe).

Robustesse : init lazy une fois pour toute la vie du process (init/shutdown
répétés coûtent 100-500 ms). Vtable IVRSystem_022 résolue par index slot
dans une struct contiguë de IntPtr (pas de struct marshaling, donc immune
aux changements de slots qu'on n'utilise pas). Si SteamVR crash en cours
de session, le service détecte au prochain tick et marque la session
broken pour retenter au tick suivant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:29:57 +02:00
211af9dd85 v0.22.0 — Health checks editor : icon picker + per-check refresh
Settings → Avancés → Bandeau de santé : liste éditable des checks (Add /
Edit / Delete) avec dialog modal pour chaque entry. Picker d'emoji parmi
28 icônes curatées (VR / streaming / réseau / système). Édition du nom,
type (Process / Ping), cible, intervalle de refresh en ms (per-check),
et seuils Ping (warn / error / timeout) sous une section Avancé.

La config est persistée dans %LocalAppData%\PSLauncher\config.json donc
elle survit aux auto-updates du launcher. Modifiable aussi à la main
dans le json pour les power-users.

Le bandeau hot-reload après Save : cancel des boucles de polling
existantes, reconstruction des HealthIndicators depuis la config fraîche,
puis relance d'une boucle dédiée par check (chacune cadencée sur son
propre RefreshIntervalMs — Process check rapide peut tourner à 1 s,
Ping reste à 5-10 s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:09:39 +02:00
741e30b7ae Layout : sidebar spans rows 1+2+3, health banner cantonné Col 1
Restructure visuelle pour que la sidebar reste le « chapeau de tout »
côté gauche, comme la top bar l'est en haut :

  ┌──────────────────────────────────────────┐
  │           Top bar (ColSpan 2)             │  Row 0
  ├─────────┬────────────────────────────────┤
  │         │  Health banner (Col 1)         │  Row 1
  │ Sidebar ├────────────────────────────────┤
  │ (Col 0  │  Content (Library / Rep / Doc) │  Row 2
  │  RowSpan│                                │
  │   = 3)  ├────────────────────────────────┤
  │         │  Footer (Col 1)                │  Row 3
  └─────────┴────────────────────────────────┘

Auparavant le health banner traversait toute la largeur (ColSpan=2),
donc la sidebar ne commençait qu'EN-DESSOUS — décalée verticalement
par rapport au logo PROSERVE de la top bar. Maintenant le banner est
cantonné à la colonne content (Col 1), la sidebar (Col 0, RowSpan=3)
court sur toute la hauteur sous la top bar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:57:25 +02:00
93f54d094a Theme : dark global ToolTip style
WPF's default ToolTip is a white-on-black system look that clashes
with the dark theme — visible whenever the user hovers a button with
ToolTip="…", a health banner pill, the « ⋯ » menu hint, etc.

Global TargetType=ToolTip style :
  Background  : Brush.Bg.Card
  Foreground  : Brush.Text.Primary
  Border      : Brush.Border, 1 px, 6 px corner radius
  Padding     : 12 × 8 (more breathing room than default)
  MaxWidth    : 360 — so the longer health-banner tooltips wrap to a
                second/third line instead of running off the right edge
  HasDropShadow : true (the only WPF-native cue that says « this is a
                  tooltip floating above » since we removed the OS skin)
  Inner TextBlock style cascades TextWrapping=Wrap on string-content
  tooltips so multi-line messages format nicely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:52:38 +02:00
d61c1d85fa Health banner Library-only (mirror of footer scoping)
Same logic as the footer : the health pills make sense only when the
user is on the Library page. On Reports / Documentation, the WebView
takes the full content area and the system-status row would just steal
vertical space without adding value (and arguably distracts from the
embedded site).

New ShowHealthBanner = HasHealthIndicators && IsLibrary, with
NotifyPropertyChangedFor on CurrentPage so the row collapses/reveals
on tab switch like the footer already does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:50:44 +02:00
60358a6cf5 v0.21.0 — System health banner with colored indicators + tooltips
New row between top bar and content showing pill-shaped status indicators
for the system dependencies that PROSERVE needs (SteamVR, Vive Business
Streaming process, VR headset reachable on the network, etc.). Each pill
is colored : 🟢 OK, 🟠 limitation détectée (e.g. ping élevé), 🔴 KO,
gris = non configuré. Hover → tooltip with full check kind, target,
last RTT/process count and timestamp.

Architecture
- LocalConfig.HealthChecksConfig : list of HealthCheckEntry (Kind=Ping
  or Process, Target=IP/host or process name, ping thresholds in ms,
  refresh interval in s).
- ISystemHealthService + SystemHealthService : ping via
  System.Net.NetworkInformation.Ping (no admin required), process
  lookup via System.Diagnostics.Process.GetProcessesByName. Returns
  HealthResult { Severity, Detail }.
- HealthIndicatorViewModel : observable wrapper per entry with
  Severity-driven Brushes (background pill, border, icon colour) and
  composed Tooltip text.
- MainViewModel.InitHealthIndicators + StartHealthLoop : populates
  ObservableCollection<HealthIndicatorViewModel> from config and runs
  parallel checks every RefreshIntervalSeconds (default 10s).
- MainWindow.xaml : new Row 1 (between top bar and body) hosting an
  ItemsControl bound to HealthIndicators. Row collapses cleanly if the
  list is empty (HasHealthIndicators=false).

Defaults shipped (all editable in %LocalAppData%\PSLauncher\config.json)
- 🎮 SteamVR (Process : vrserver)
- 📡 Vive Business Streaming (Process : HtcConnectionUtility)
- 🥽 Casque VR (Ping : empty, user fills in the headset IP)

The Settings UI editor for managing the list is intentionally deferred
to a future iteration — config.json edit is enough to start using it.

Versions bumped to 0.21.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:44:15 +02:00
30eceaea2c v0.20.0 — Cancel-from-red-button waits for segments + new Verifying row state
Two related UX bugs fixed.

1) Red "Annuler" button on a row : DL appeared to keep going

The button calls RestartFromZeroAsync which used to fire
_activeDownloadCts.Cancel() then sleep 200 ms then delete the .partial.
With 16 parallel segments mid-write, 200 ms is way too short — the
segments were still flushing buffers when the file got deleted, then
recreated it from their own write streams, making it look like the
download "continued".

Now we keep a reference to the in-flight install Task
(_activeInstallTask) and properly await it (with a 10 s safety timeout)
before discarding state. This guarantees all segment FileStreams are
closed and the .partial deletion is the final word.

2) Progress bar said "Downloading…" during SHA-256 verification

The footer message switched to "🔍 Vérification SHA-256 v…" but the
row's badge stayed on "⬇ Downloading…" because row.State stayed at
Downloading throughout DownloadAsync (which internally chains DL +
verify). New VersionRowState.Verifying inserted between Downloading
and Installing, applied on the first hashProgress callback. Badge now
reads "🔍 Vérification…" / "🔍 Verifying…" during the hash phase.

VersionRowViewModel.IsBusy now also includes Verifying so commands
that gate on busy stay disabled during verification.

Versions bumped to 0.20.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:34:18 +02:00
3c8438f3fe v0.19.0 — Bump default parallel segments 8→16 to shorten end-of-DL tail
Issue : on a 14 GB download with 8 segments, when 7 finish there's only
1 connection left handling its assigned 1.75 GB → apparent throughput
collapses to 1/8th of the peak for the final stretch (~5 min visible
slowdown at the end).

Quick fix : double the default segment count.
- 8 segments  : last has 1.75 GB to finish alone
- 16 segments : last has 875 MB → tail ~halved

Tuning surfaced in Settings → Avancés → Serveur as
« Connexions parallèles pour les téléchargements (1-32, défaut 16) ».
Power users on faster servers can push to 24-32. OVH mutualisé tolerates
up to 16-24 ; above that, soft rate-limiting kicks in.

Aligned :
- LocalConfig default : 8 → 16
- DownloadManager clamp : Math.Clamp(_, 1, 16) → 1, 32
- HttpClient handler MaxConnectionsPerServer : 16 → 32 (matches new ceiling)

Versions bumped to 0.19.0.

Existing config.json files will keep their previous value (8) untouched ;
delete config.json to pick up the new default, or just bump it in
Settings → Avancés.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:28:35 +02:00
0bd4c8a4be Theme : pull palette back towards black (v0.18 was too navy)
User feedback : the blue shift was too much. Reining in the saturation:
  Window  : #0A1220 → #050A14
  Sidebar : #0E1422 → #0B1018
  Card    : #161D2C → #131826
  Footer  : #060A14 → #04070D
  BlueTint overlay : 8% opacity → 4%

The result is a near-black palette with just a hint of blue depth, rather
than a clearly navy one. Stays in the dark-theme zone while keeping a
discreet ASTERION VR vibe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:17:28 +02:00
8366ca2669 v0.18.0 — Bluer dark palette to match ASTERION VR identity
Shifts the launcher background from pure black to deep navy and adds a
subtle blue tint over the body bitmap so the overall feel is closer to
the brand's blue/cyan accent rather than neutral black-on-grey.

Theme.xaml palette tweaks
- Brush.Bg.Window  : #000000 → #0A1220 (deep navy, ~95% black)
- Brush.Bg.Sidebar : #0E1218 → #0E1422
- Brush.Bg.Card    : #161B23 → #161D2C
- Brush.Bg.Footer  : #050709 → #060A14
- New Brush.Bg.BlueTint : #3050A0 @ 8% opacity, used as overlay

MainWindow.xaml
- Outer Grid Background : hardcoded "Black" → Brush.Bg.Window
  (so future tweaks of the brush propagate)
- New Rectangle on top of the body Background.png with the blue tint
  brush, IsHitTestVisible=False so it doesn't block clicks.

Versions bumped to 0.18.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:30:24 +02:00
e608a5d29d v0.17.0 — Doc tool auto-deploy + revert (mirror of Report)
Adds the same deploy + backup/revert mechanism as Report, but for the
local Documentation web tool :
  PROSERVE-source/_doc/  →  C:\xampp\htdocs\ProserveDoc\
  http://localhost/ProserveDoc/

Default DocumentationUrl in the launcher set to http://localhost/ProserveDoc/
so the Documentation sidebar tab works out of the box on a fresh install
without any manual config.

Implementation
- LocalConfig.DocToolConfig (HtdocsRoot/FolderName/AutoDeploy/MaxBackups,
  defaults to ProserveDoc folder, 3 backups kept).
- IDocToolDeployer + DocToolDeployer : near-duplicate of the Report
  deployer with SourceSubdir = "_doc". Reuses ReportTool's BackupInfo /
  DeployStatus / DeployResult / DeployProgress types so we don't fork
  the data model.
- MainViewModel : new install step 7 right after the Report deploy step,
  mirrors its progress reporting + error dialogs (silent on
  XamppNotFound since the Report step already warned for the same root).
- SettingsViewModel : DocBackupViewModel + Doc properties + RedeployDoc
  + Revert commands. Loads the doc backups list in background like
  the Report ones.
- SettingsDialog.xaml : new « OUTIL DOCUMENTATION » card under the
  « OUTIL REPORT » one in Avancés, with the same fields and backup table.
- Strings.cs : doc-specific status / progress / error labels in 5 langs ;
  reuses some labels from Report where the wording is identical.

Versions bumped to 0.17.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 11:18:13 +02:00
24 changed files with 2956 additions and 50 deletions

View File

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher" #define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher" #define MyAppShortName "PS_Launcher"
#define MyAppVersion "0.16.0" #define MyAppVersion "0.23.5"
#define MyAppPublisher "ASTERION VR" #define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com" #define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe" #define MyAppExeName "PS_Launcher.exe"

View File

@@ -16,6 +16,9 @@ using PSLauncher.Core.Integrity;
using PSLauncher.Core.Licensing; using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization; using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests; using PSLauncher.Core.Manifests;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Health;
using PSLauncher.Core.Health.OpenVRInterop;
using PSLauncher.Core.Migrations; using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process; using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool; using PSLauncher.Core.ReportTool;
@@ -94,7 +97,19 @@ public partial class App : Application
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory); Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
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");
@@ -111,16 +126,16 @@ public partial class App : Application
services.AddSingleton(sp => services.AddSingleton(sp =>
{ {
// Handler tuné pour les gros téléchargements parallèles : // Handler tuné pour les gros téléchargements parallèles :
// - MaxConnectionsPerServer=16 pour autoriser le multi-segment (8 par défaut). // - MaxConnectionsPerServer=32 pour aligner avec le clamp DownloadManager (8-32).
// - AutomaticDecompression sur l'API JSON uniquement (None ici car les builds // Default WPF = 8 → trop bas pour notre multi-segment.
// sont des ZIPs déjà compressés ; éviter la CPU+RAM gaspillée par un éventuel // - AutomaticDecompression=None : les builds sont des ZIPs déjà compressés ;
// wrapping gzip côté serveur). // é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. // - PooledConnectionLifetime court pour éviter qu'OVH ferme nos sockets sous nous.
var handler = new SocketsHttpHandler var handler = new SocketsHttpHandler
{ {
PooledConnectionLifetime = TimeSpan.FromMinutes(5), PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60), PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
MaxConnectionsPerServer = 16, MaxConnectionsPerServer = 32,
EnableMultipleHttp2Connections = true, EnableMultipleHttp2Connections = true,
AutomaticDecompression = System.Net.DecompressionMethods.None, AutomaticDecompression = System.Net.DecompressionMethods.None,
}; };
@@ -182,6 +197,16 @@ public partial class App : Application
configProvider: () => sp.GetRequiredService<LocalConfig>().ReportTool, configProvider: () => sp.GetRequiredService<LocalConfig>().ReportTool,
sp.GetRequiredService<ILogger<ReportToolDeployer>>())); 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>>()));
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>();

View File

@@ -15,9 +15,9 @@
<Product>PROSERVE Launcher</Product> <Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright> <Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace> <RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.16.0</Version> <Version>0.23.5</Version>
<AssemblyVersion>0.16.0.0</AssemblyVersion> <AssemblyVersion>0.23.5.0</AssemblyVersion>
<FileVersion>0.16.0.0</FileVersion> <FileVersion>0.23.5.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>

View File

@@ -11,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" />
@@ -281,6 +287,47 @@
</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. <!-- ComboBox sombre.
Re-template minimal pour avoir le contenu, l'arrow et le popup tous en Re-template minimal pour avoir le contenu, l'arrow et le popup tous en
palette dark. WPF native ComboBox style serait grisé sur blanc. --> palette dark. WPF native ComboBox style serait grisé sur blanc. -->

View 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;
}
}

View File

@@ -14,6 +14,8 @@ using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing; using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization; using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests; using PSLauncher.Core.Manifests;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Health;
using PSLauncher.Core.Migrations; using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process; using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool; using PSLauncher.Core.ReportTool;
@@ -43,6 +45,8 @@ public sealed partial class MainViewModel : ObservableObject
private readonly IToastService _toastService; private readonly IToastService _toastService;
private readonly IDatabaseMigrationService _migrationService; private readonly IDatabaseMigrationService _migrationService;
private readonly IReportToolDeployer _reportDeployer; private readonly IReportToolDeployer _reportDeployer;
private readonly IDocToolDeployer _docDeployer;
private readonly ISystemHealthService _healthService;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly ILogger<MainViewModel> _logger; private readonly ILogger<MainViewModel> _logger;
@@ -51,9 +55,40 @@ public sealed partial class MainViewModel : ObservableObject
private RemoteManifest? _lastManifest; private RemoteManifest? _lastManifest;
private CancellationTokenSource? _activeDownloadCts; private CancellationTokenSource? _activeDownloadCts;
private VersionRowViewModel? _activeRow; private VersionRowViewModel? _activeRow;
/// <summary>
/// Référence au Task de l'install/DL en cours, exposée aux commandes externes
/// (ex. RestartFromZero) qui ont besoin d'attendre la fin réelle des écritures
/// disque avant de supprimer le partial. Sinon les segments continuent à écrire
/// pendant 1-2 sec après le Cancel() et recréent le fichier qu'on vient de delete.
/// </summary>
private Task? _activeInstallTask;
public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new(); public ObservableCollection<VersionRowViewModel> OtherVersions { get; } = new();
/// <summary>
/// Indicateurs affichés dans le bandeau de santé sous la top bar : pings,
/// processus requis (SteamVR, Vive Streaming, etc.). Liste construite depuis
/// <see cref="HealthChecksConfig.Checks"/> au démarrage. Refresh périodique
/// piloté par <see cref="StartHealthLoop"/>.
/// </summary>
public ObservableCollection<HealthIndicatorViewModel> HealthIndicators { get; } = new();
public bool HasHealthIndicators => HealthIndicators.Count > 0;
/// <summary>
/// Cancelle les boucles de polling per-check actuellement en vol. Recréée à
/// chaque appel à <see cref="StartHealthLoop"/> pour permettre un redémarrage
/// propre après édition des checks dans Settings (hot-reload sans relancer
/// l'app).
/// </summary>
private CancellationTokenSource? _healthLoopCts;
/// <summary>
/// Le bandeau santé n'a de sens que sur la page Library — Reports et
/// Documentation sont des WebView pleine-page où afficher des pills
/// de monitoring système n'apporte rien et coupe visuellement.
/// </summary>
public bool ShowHealthBanner => HasHealthIndicators && IsLibrary;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasFeaturedVersion))] [NotifyPropertyChangedFor(nameof(HasFeaturedVersion))]
[NotifyPropertyChangedFor(nameof(HasOtherVersions))] [NotifyPropertyChangedFor(nameof(HasOtherVersions))]
@@ -90,6 +125,7 @@ public sealed partial class MainViewModel : ObservableObject
[NotifyPropertyChangedFor(nameof(IsDocumentation))] [NotifyPropertyChangedFor(nameof(IsDocumentation))]
[NotifyPropertyChangedFor(nameof(FooterVisibility))] [NotifyPropertyChangedFor(nameof(FooterVisibility))]
[NotifyPropertyChangedFor(nameof(FooterText))] [NotifyPropertyChangedFor(nameof(FooterText))]
[NotifyPropertyChangedFor(nameof(ShowHealthBanner))]
private LauncherPage _currentPage = LauncherPage.Library; private LauncherPage _currentPage = LauncherPage.Library;
public bool IsLibrary => CurrentPage == LauncherPage.Library; public bool IsLibrary => CurrentPage == LauncherPage.Library;
@@ -226,6 +262,8 @@ public sealed partial class MainViewModel : ObservableObject
IToastService toastService, IToastService toastService,
IDatabaseMigrationService migrationService, IDatabaseMigrationService migrationService,
IReportToolDeployer reportDeployer, IReportToolDeployer reportDeployer,
IDocToolDeployer docDeployer,
ISystemHealthService healthService,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
ILogger<MainViewModel> logger) ILogger<MainViewModel> logger)
{ {
@@ -242,6 +280,8 @@ public sealed partial class MainViewModel : ObservableObject
_toastService = toastService; _toastService = toastService;
_migrationService = migrationService; _migrationService = migrationService;
_reportDeployer = reportDeployer; _reportDeployer = reportDeployer;
_docDeployer = docDeployer;
_healthService = healthService;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_logger = logger; _logger = logger;
@@ -250,6 +290,7 @@ public sealed partial class MainViewModel : ObservableObject
_license = _licenseService.GetCached(); _license = _licenseService.GetCached();
RebuildList(); RebuildList();
InitHealthIndicators();
// Vérification automatique des MAJ au démarrage (silencieuse et non bloquante) // Vérification automatique des MAJ au démarrage (silencieuse et non bloquante)
_ = Task.Run(async () => _ = Task.Run(async () =>
@@ -264,6 +305,75 @@ public sealed partial class MainViewModel : ObservableObject
} }
catch (Exception ex) { _logger.LogWarning(ex, "Auto check at startup failed"); } catch (Exception ex) { _logger.LogWarning(ex, "Auto check at startup failed"); }
}); });
// Boucle périodique des health checks. Ne tourne que si la config en
// contient au moins un. Premier passage immédiat puis re-check tous les
// RefreshIntervalSeconds.
StartHealthLoop();
}
/// <summary>
/// Construit la collection observable des indicateurs depuis la config et
/// notifie HasHealthIndicators pour que la UI cache le bandeau si la liste
/// est vide. Idempotent — appelée au startup uniquement, pas à chaque refresh.
/// </summary>
private void InitHealthIndicators()
{
HealthIndicators.Clear();
foreach (var entry in _config.HealthChecks.Checks)
HealthIndicators.Add(new HealthIndicatorViewModel(entry));
OnPropertyChanged(nameof(HasHealthIndicators));
OnPropertyChanged(nameof(ShowHealthBanner));
}
/// <summary>
/// Lance une boucle indépendante par check, chacune cadencée sur son propre
/// <see cref="HealthCheckEntry.RefreshIntervalMs"/>. Un Process check rapide
/// (~1 ms) peut tourner toutes les 1-2 s sans impact CPU, alors qu'un Ping
/// (qui coûte un round-trip réseau) tourne typiquement à 5-10 s. Premier
/// passage immédiat puis re-check selon l'intervalle.
///
/// Cancelle la boucle précédente si elle existe (cas du hot-reload depuis
/// Settings → édition des checks).
/// </summary>
private void StartHealthLoop()
{
// Stop l'ancienne boucle si on est en hot-reload (édition Settings)
try { _healthLoopCts?.Cancel(); } catch { /* déjà disposée */ }
_healthLoopCts?.Dispose();
_healthLoopCts = null;
if (HealthIndicators.Count == 0) return;
_healthLoopCts = new CancellationTokenSource();
var ct = _healthLoopCts.Token;
foreach (var vm in HealthIndicators.ToList())
{
var localVm = vm;
// Floor de 500 ms pour éviter qu'un user mette 0/1 ms par erreur dans le json
// et sature un coeur CPU à pleins gaz.
var intervalMs = Math.Max(500, localVm.Entry.RefreshIntervalMs);
_ = Task.Run(async () =>
{
while (!ct.IsCancellationRequested)
{
try
{
var result = await _healthService.RunCheckAsync(localVm.Entry, ct).ConfigureAwait(false);
if (ct.IsCancellationRequested) break;
await Application.Current.Dispatcher.InvokeAsync(() => localVm.Apply(result));
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogDebug(ex, "Health check {Name} threw", localVm.Entry.Name);
}
try { await Task.Delay(intervalMs, ct).ConfigureAwait(false); }
catch { break; }
}
}, ct);
}
} }
/// <summary> /// <summary>
@@ -329,7 +439,7 @@ public sealed partial class MainViewModel : ObservableObject
private void WireRowHandlers(VersionRowViewModel row) private void WireRowHandlers(VersionRowViewModel row)
{ {
row.LaunchHandler = LaunchVersion; row.LaunchHandler = LaunchVersion;
row.InstallHandler = r => _ = InstallVersionAsync(r); row.InstallHandler = r => _activeInstallTask = InstallVersionAsync(r);
row.UninstallHandler = r => _ = UninstallVersionAsync(r); row.UninstallHandler = r => _ = UninstallVersionAsync(r);
row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r); row.ShowReleaseNotesHandler = r => _ = ShowReleaseNotesAsync(r);
row.OpenFolderHandler = OpenVersionFolder; row.OpenFolderHandler = OpenVersionFolder;
@@ -456,6 +566,15 @@ public sealed partial class MainViewModel : ObservableObject
_license = _licenseService.GetCached(); _license = _licenseService.GetCached();
NotifyLicenseChanged(); NotifyLicenseChanged();
RebuildList(); RebuildList();
// Hot-reload du bandeau de santé : la liste des checks (et leurs intervals)
// a pu être éditée. On reconstruit les indicateurs et on relance la boucle
// per-check sans avoir à fermer/rouvrir le launcher.
InitHealthIndicators();
StartHealthLoop();
OnPropertyChanged(nameof(ReportUri));
OnPropertyChanged(nameof(DocumentationUri));
OnPropertyChanged(nameof(HasDocumentationUrl));
} }
} }
@@ -541,12 +660,26 @@ public sealed partial class MainViewModel : ObservableObject
MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No); MessageBoxButton.YesNo, MessageBoxImage.Warning, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return; if (confirm != MessageBoxResult.Yes) return;
// Cancel le DL actif si c'est cette row // Cancel le DL actif si c'est cette row, puis attend la propagation complète
// (les 16 segments doivent chacun stopper leurs écritures et fermer leur
// FileStream sinon DiscardResumableState échouera/sera réécrasé).
if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null) if (_activeRow is not null && _activeRow == row && _activeDownloadCts is not null)
{ {
_activeDownloadCts.Cancel(); _activeDownloadCts.Cancel();
// Attend la fin de la tâche en cours pour libérer les handles fichier // Attend que InstallVersionAsync termine son catch OperationCanceledException
try { await Task.Delay(200); } catch { } // — y compris le flush + state save + libération des FileStream segment.
// Timeout de sécurité 10s au cas où une connexion HTTPS bloque sur close.
if (_activeInstallTask is { } running && !running.IsCompleted)
{
StatusMessage = "Annulation en cours…";
try
{
var awaitTask = await Task.WhenAny(running, Task.Delay(TimeSpan.FromSeconds(10)));
if (!ReferenceEquals(awaitTask, running))
_logger.LogWarning("Install task did not finish cancelling within 10s, proceeding anyway");
}
catch (Exception ex) { _logger.LogDebug(ex, "Awaiting install task during restart"); }
}
} }
_downloadManager.DiscardResumableState(row.Version); _downloadManager.DiscardResumableState(row.Version);
@@ -678,9 +811,13 @@ public sealed partial class MainViewModel : ObservableObject
row.ProgressPercent = 0; row.ProgressPercent = 0;
var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p)); var dlProgress = new Progress<DownloadProgress>(p => UpdateDlProgress(row, p));
// Reporter dédié pour la phase verify SHA-256 (peut prendre 30s-3min sur 14 Go). // Reporter dédié pour la phase verify SHA-256 (peut prendre 30s-3min sur 14 Go).
// On bascule le StatusMessage à ce moment-là pour que l'utilisateur sache ce qui se passe. // On bascule row.State sur Verifying à la première remontée, ce qui change
// le badge de la row de "⬇ Téléchargement…" vers "🔍 Vérification…" — les
// deux phases sont distinctes côté UX bien que `DownloadAsync` les enchaîne.
var hashProgress = new Progress<double>(pct => var hashProgress = new Progress<double>(pct =>
{ {
if (row.State == VersionRowState.Downloading)
row.State = VersionRowState.Verifying;
var percent = (int)Math.Round(pct * 100.0); var percent = (int)Math.Round(pct * 100.0);
ProgressPercent = percent; ProgressPercent = percent;
row.ProgressPercent = percent; row.ProgressPercent = percent;
@@ -793,6 +930,49 @@ public sealed partial class MainViewModel : ObservableObject
} }
} }
// 7) Déploiement de l'outil Documentation (parallèle à Report).
// Cherche _doc/ dans le ZIP extrait et copie vers htdocs en
// atomic rename. Skip silencieux si _doc/ absent du ZIP.
if (_config.DocTool.AutoDeploy)
{
StatusMessage = Strings.StatusDeployingDoc(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
var ddProgress = new Progress<DeployProgress>(dp =>
{
if (dp.FilesTotal > 0)
{
var pct = (double)dp.FilesDone / dp.FilesTotal * 100.0;
ProgressPercent = pct;
row.ProgressPercent = pct;
}
var label = Strings.ProgressDocDeploy(dp.FilesDone, dp.FilesTotal, dp.CurrentFilename);
ProgressDetail = label;
row.ProgressDetail = label;
});
var ddResult = await _docDeployer.DeployAsync(target, ddProgress, ct);
switch (ddResult.Status)
{
case DeployStatus.Deployed:
_logger.LogInformation("Doc tool deployed ({N} files) for v{Version}", ddResult.FilesDeployed, row.Version);
break;
case DeployStatus.SkippedNoSourceFolder:
_logger.LogInformation("No _doc/ in v{Version} ZIP, doc tool unchanged", row.Version);
break;
case DeployStatus.XamppNotFound:
// Le warning Report a déjà été affiché si nécessaire ; on reste silencieux ici
// (même cause = même symptôme, inutile de faire 2 popups identiques).
_logger.LogWarning("Doc tool deploy : XAMPP htdocs not found at {Root}", _config.DocTool.HtdocsRoot);
break;
case DeployStatus.Failed:
ThemedMessageBox.Show(
Strings.MsgDocDeployFailed(ddResult.ErrorMessage ?? "?"),
Strings.MsgBoxDocDeployFailed,
MessageBoxButton.OK, MessageBoxImage.Warning);
break;
}
}
StatusMessage = Strings.StatusInstalledSuccess(row.Version); StatusMessage = Strings.StatusInstalledSuccess(row.Version);
ProgressDetail = null; ProgressDetail = null;
ProgressPercent = 0; ProgressPercent = 0;
@@ -832,6 +1012,7 @@ public sealed partial class MainViewModel : ObservableObject
_activeDownloadCts?.Dispose(); _activeDownloadCts?.Dispose();
_activeDownloadCts = null; _activeDownloadCts = null;
_activeRow = null; _activeRow = null;
_activeInstallTask = null;
IsBusy = false; IsBusy = false;
} }
} }

View File

@@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PSLauncher.App.Views; using PSLauncher.App.Views;
using PSLauncher.Core.Configuration; using PSLauncher.Core.Configuration;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Downloads; using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations; using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing; using PSLauncher.Core.Licensing;
@@ -33,6 +34,7 @@ public sealed partial class SettingsViewModel : ObservableObject
private readonly IDownloadStateStore _downloadStore; private readonly IDownloadStateStore _downloadStore;
private readonly IDatabaseMigrationService _migrationService; private readonly IDatabaseMigrationService _migrationService;
private readonly IReportToolDeployer _reportDeployer; private readonly IReportToolDeployer _reportDeployer;
private readonly IDocToolDeployer _docDeployer;
private readonly IInstallationRegistry _registry; private readonly IInstallationRegistry _registry;
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly ILogger<SettingsViewModel> _logger; private readonly ILogger<SettingsViewModel> _logger;
@@ -47,6 +49,7 @@ public sealed partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _language; [ObservableProperty] private string _language;
[ObservableProperty] private string _reportUrl; [ObservableProperty] private string _reportUrl;
[ObservableProperty] private string _documentationUrl; [ObservableProperty] private string _documentationUrl;
[ObservableProperty] private int _parallelSegments;
// ----- DB Migration settings ----- // ----- DB Migration settings -----
[ObservableProperty] private string _dbHost; [ObservableProperty] private string _dbHost;
@@ -71,6 +74,28 @@ public sealed partial class SettingsViewModel : ObservableObject
= new(); = new();
public bool HasReportBackups => ReportBackups.Count > 0; public bool HasReportBackups => ReportBackups.Count > 0;
// ----- Doc Tool deploy settings (mirror of Report) -----
[ObservableProperty] private string _docHtdocsRoot;
[ObservableProperty] private string _docFolderName;
[ObservableProperty] private bool _docAutoDeploy;
[ObservableProperty] private int _docMaxBackups;
[ObservableProperty] private string? _docDeployStatus;
[ObservableProperty] private bool _isDeployingDoc;
public System.Collections.ObjectModel.ObservableCollection<DocBackupViewModel> DocBackups { get; }
= new();
public bool HasDocBackups => DocBackups.Count > 0;
// ----- Health checks (bandeau de santé système) -----
/// <summary>
/// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne
/// pointe vers une copie modifiable de l'entrée (l'original dans
/// <c>_config.HealthChecks.Checks</c> n'est écrasé qu'à <see cref="Save"/>),
/// pour que Cancel ne laisse pas de modifs partielles dans la config.
/// </summary>
public System.Collections.ObjectModel.ObservableCollection<HealthCheckRowViewModel> HealthChecks { get; }
= new();
public bool HasHealthChecks => HealthChecks.Count > 0;
public IReadOnlyList<LanguageOption> AvailableLanguages { get; } = public IReadOnlyList<LanguageOption> AvailableLanguages { get; } =
PSLauncher.Core.Localization.Strings.Available PSLauncher.Core.Localization.Strings.Available
.Select(t => new LanguageOption(t.Code, t.Name)) .Select(t => new LanguageOption(t.Code, t.Name))
@@ -139,6 +164,7 @@ public sealed partial class SettingsViewModel : ObservableObject
IDownloadStateStore downloadStore, IDownloadStateStore downloadStore,
IDatabaseMigrationService migrationService, IDatabaseMigrationService migrationService,
IReportToolDeployer reportDeployer, IReportToolDeployer reportDeployer,
IDocToolDeployer docDeployer,
IInstallationRegistry registry, IInstallationRegistry registry,
HttpClient http, HttpClient http,
ILogger<SettingsViewModel> logger) ILogger<SettingsViewModel> logger)
@@ -149,6 +175,7 @@ public sealed partial class SettingsViewModel : ObservableObject
_downloadStore = downloadStore; _downloadStore = downloadStore;
_migrationService = migrationService; _migrationService = migrationService;
_reportDeployer = reportDeployer; _reportDeployer = reportDeployer;
_docDeployer = docDeployer;
_registry = registry; _registry = registry;
_http = http; _http = http;
_logger = logger; _logger = logger;
@@ -158,6 +185,7 @@ public sealed partial class SettingsViewModel : ObservableObject
_language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language; _language = string.IsNullOrWhiteSpace(config.Language) ? "auto" : config.Language;
_reportUrl = config.ReportUrl; _reportUrl = config.ReportUrl;
_documentationUrl = config.DocumentationUrl; _documentationUrl = config.DocumentationUrl;
_parallelSegments = config.ParallelDownloadSegments;
_dbHost = config.Database.Host; _dbHost = config.Database.Host;
_dbPort = config.Database.Port; _dbPort = config.Database.Port;
@@ -170,8 +198,20 @@ public sealed partial class SettingsViewModel : ObservableObject
_reportFolderName = config.ReportTool.FolderName; _reportFolderName = config.ReportTool.FolderName;
_reportAutoDeploy = config.ReportTool.AutoDeploy; _reportAutoDeploy = config.ReportTool.AutoDeploy;
_reportMaxBackups = config.ReportTool.MaxBackups; _reportMaxBackups = config.ReportTool.MaxBackups;
// Charge la liste de backups en arrière-plan (UI immédiate puis remplie quand prête)
_docHtdocsRoot = config.DocTool.HtdocsRoot;
_docFolderName = config.DocTool.FolderName;
_docAutoDeploy = config.DocTool.AutoDeploy;
_docMaxBackups = config.DocTool.MaxBackups;
// Snapshot éditable des health checks. On clone chaque entry pour ne pas
// muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save.
foreach (var entry in config.HealthChecks.Checks)
HealthChecks.Add(new HealthCheckRowViewModel(CloneEntry(entry), DeleteHealthCheck));
// Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes)
_ = LoadReportBackupsAsync(); _ = LoadReportBackupsAsync();
_ = LoadDocBackupsAsync();
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
MachineId = licenseService.GetMachineId(); MachineId = licenseService.GetMachineId();
@@ -281,6 +321,7 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.Language = Language ?? "auto"; _config.Language = Language ?? "auto";
_config.ReportUrl = ReportUrl?.Trim() ?? ""; _config.ReportUrl = ReportUrl?.Trim() ?? "";
_config.DocumentationUrl = DocumentationUrl?.Trim() ?? ""; _config.DocumentationUrl = DocumentationUrl?.Trim() ?? "";
_config.ParallelDownloadSegments = Math.Clamp(ParallelSegments, 1, 32);
// DB settings // DB settings
_config.Database.Host = DbHost.Trim(); _config.Database.Host = DbHost.Trim();
@@ -297,6 +338,16 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.ReportTool.AutoDeploy = ReportAutoDeploy; _config.ReportTool.AutoDeploy = ReportAutoDeploy;
_config.ReportTool.MaxBackups = Math.Max(0, ReportMaxBackups); _config.ReportTool.MaxBackups = Math.Max(0, ReportMaxBackups);
_config.DocTool.HtdocsRoot = DocHtdocsRoot.Trim();
_config.DocTool.FolderName = DocFolderName.Trim();
_config.DocTool.AutoDeploy = DocAutoDeploy;
_config.DocTool.MaxBackups = Math.Max(0, DocMaxBackups);
// Health checks : on remplace intégralement la liste persistée par
// les copies éditées. Les originaux restent en mémoire de toute façon
// (les VM les ont clonés), donc Cancel a déjà préservé l'état initial.
_config.HealthChecks.Checks = HealthChecks.Select(r => r.Entry).ToList();
_configStore.Save(_config); _configStore.Save(_config);
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged); _logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
@@ -553,6 +604,143 @@ public sealed partial class SettingsViewModel : ObservableObject
IsDeployingReport = false; IsDeployingReport = false;
} }
} }
// ===================== DOC TOOL BACKUPS (mirror of Report) =====================
private async Task LoadDocBackupsAsync()
{
try
{
var list = await _docDeployer.ListBackupsAsync(CancellationToken.None);
DocBackups.Clear();
foreach (var b in list)
DocBackups.Add(new DocBackupViewModel(b, RevertDocToBackupAsync));
OnPropertyChanged(nameof(HasDocBackups));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not list doc backups");
}
}
private async Task RevertDocToBackupAsync(BackupInfo backup)
{
var localDate = backup.TimestampUtc.ToLocalTime();
var dateLabel = Strings.FormatLongDate(localDate);
var confirm = ThemedMessageBox.Show(
Strings.MsgRevertReportConfirm(dateLabel),
Strings.MsgBoxRevertReport,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
IsDeployingDoc = true;
DocDeployStatus = $"Revert vers {dateLabel}…";
try
{
var result = await _docDeployer.RevertAsync(backup.FolderName, CancellationToken.None);
DocDeployStatus = result.Status == DeployStatus.Deployed
? Strings.MsgRevertReportSuccess(dateLabel)
: $"✗ {result.ErrorMessage}";
await LoadDocBackupsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Revert doc failed");
DocDeployStatus = $"✗ {ex.Message}";
}
finally
{
IsDeployingDoc = false;
}
}
// ===================== HEALTH CHECKS =====================
[RelayCommand]
private void AddHealthCheck()
{
var entry = new HealthCheckEntry
{
Name = "",
Icon = "🔌",
Kind = "Process",
Target = "",
RefreshIntervalMs = 5000,
};
var dlg = new HealthCheckEditorDialog(entry, isNew: true)
{
Owner = Application.Current.MainWindow
};
if (dlg.ShowDialog() == true && dlg.Saved)
{
HealthChecks.Add(new HealthCheckRowViewModel(entry, DeleteHealthCheck));
OnPropertyChanged(nameof(HasHealthChecks));
}
}
private void DeleteHealthCheck(HealthCheckRowViewModel row)
{
var confirm = ThemedMessageBox.Show(
Strings.SettingsHealthDeleteConfirm(string.IsNullOrEmpty(row.Name) ? row.Kind : row.Name),
Strings.MsgBoxConfirm,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
HealthChecks.Remove(row);
OnPropertyChanged(nameof(HasHealthChecks));
}
private static HealthCheckEntry CloneEntry(HealthCheckEntry src) => new()
{
Name = src.Name,
Icon = src.Icon,
Kind = src.Kind,
Target = src.Target,
RefreshIntervalMs = src.RefreshIntervalMs,
PingWarnMs = src.PingWarnMs,
PingErrorMs = src.PingErrorMs,
PingTimeoutMs = src.PingTimeoutMs,
};
[RelayCommand]
private async Task RedeployDocAsync()
{
var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
if (installed is null)
{
DocDeployStatus = "Aucune version installée — installe d'abord une version qui contient _doc/.";
return;
}
IsDeployingDoc = true;
DocDeployStatus = $"Déploiement depuis v{installed.Version}…";
try
{
Save();
var progress = new Progress<DeployProgress>(dp =>
{
if (dp.FilesTotal > 0)
DocDeployStatus = $"{dp.FilesDone}/{dp.FilesTotal} : {dp.CurrentFilename}";
});
var result = await _docDeployer.DeployAsync(installed.FolderPath, progress, CancellationToken.None);
DocDeployStatus = result.Status switch
{
DeployStatus.Deployed => $"✓ {result.FilesDeployed} fichiers déployés vers {result.TargetPath}",
DeployStatus.SkippedNoSourceFolder => $"⚠ La version v{installed.Version} ne contient pas de _doc/ — rien à déployer",
DeployStatus.XamppNotFound => $"✗ {result.ErrorMessage}",
DeployStatus.Failed => $"✗ {result.ErrorMessage}",
_ => "?",
};
await LoadDocBackupsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Redeploy doc failed");
DocDeployStatus = $"✗ {ex.Message}";
}
finally
{
IsDeployingDoc = false;
}
}
} }
/// <summary> /// <summary>
@@ -576,3 +764,82 @@ public sealed partial class ReportBackupViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private Task Revert() => _revertHandler(Info); private Task Revert() => _revertHandler(Info);
} }
/// <summary>
/// Ligne du tableau "Bandeau de santé système" dans Settings → Avancés. Pointe
/// vers une copie éditable de l'entrée (clonée à l'ouverture des Settings) ;
/// la persistence vers <c>config.HealthChecks.Checks</c> n'a lieu qu'au Save.
/// </summary>
public sealed partial class HealthCheckRowViewModel : ObservableObject
{
public HealthCheckEntry Entry { get; private set; }
/// <summary>Re-publié à chaque édition pour rafraîchir le label dans la liste.</summary>
[ObservableProperty]
private string _displayName;
[ObservableProperty]
private string _displayIcon;
[ObservableProperty]
private string _displayDetail;
public string Name => Entry.Name;
public string Kind => Entry.Kind;
private readonly Action<HealthCheckRowViewModel> _onDelete;
public HealthCheckRowViewModel(HealthCheckEntry entry, Action<HealthCheckRowViewModel> onDelete)
{
Entry = entry;
_onDelete = onDelete;
_displayName = string.IsNullOrEmpty(entry.Name) ? "(sans nom)" : entry.Name;
_displayIcon = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon;
_displayDetail = ComputeDetail(entry);
}
private static string ComputeDetail(HealthCheckEntry e)
{
var kind = e.Kind?.Equals("ping", StringComparison.OrdinalIgnoreCase) == true ? "Ping" : "Process";
var target = string.IsNullOrEmpty(e.Target) ? "—" : e.Target;
return $"{kind} · {target} · {e.RefreshIntervalMs} ms";
}
[RelayCommand]
private void Edit()
{
var dlg = new PSLauncher.App.Views.HealthCheckEditorDialog(Entry)
{
Owner = Application.Current.MainWindow
};
if (dlg.ShowDialog() == true && dlg.Saved)
{
// Entry a été muté in-place par le dialog. Refresh des bindings d'affichage.
DisplayName = string.IsNullOrEmpty(Entry.Name) ? "(sans nom)" : Entry.Name;
DisplayIcon = string.IsNullOrEmpty(Entry.Icon) ? "🔌" : Entry.Icon;
DisplayDetail = ComputeDetail(Entry);
}
}
[RelayCommand]
private void Delete() => _onDelete(this);
}
/// <summary>Variante doc — même structure que <see cref="ReportBackupViewModel"/>.</summary>
public sealed partial class DocBackupViewModel : ObservableObject
{
public BackupInfo Info { get; }
public string DateDisplay => Strings.FormatLongDate(Info.TimestampUtc.ToLocalTime());
public string SizeDisplay => Strings.SettingsReportBackupSize(Strings.FormatSize(Info.SizeBytes));
public string FolderName => Info.FolderName;
private readonly Func<BackupInfo, Task> _revertHandler;
public DocBackupViewModel(BackupInfo info, Func<BackupInfo, Task> revertHandler)
{
Info = info;
_revertHandler = revertHandler;
}
[RelayCommand]
private Task Revert() => _revertHandler(Info);
}

View File

@@ -10,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,
} }
@@ -77,13 +78,17 @@ public sealed partial class VersionRowViewModel : ObservableObject
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 => Strings.StatusInstalled, VersionRowState.InstalledIdle => Strings.StatusInstalled,
VersionRowState.AvailableIdle => Strings.StatusAvailable, VersionRowState.AvailableIdle => Strings.StatusAvailable,
VersionRowState.Downloading => Strings.StatusDownloading, VersionRowState.Downloading => Strings.StatusDownloading,
VersionRowState.Verifying => Strings.StatusVerifying,
VersionRowState.Installing => Strings.StatusInstalling, VersionRowState.Installing => Strings.StatusInstalling,
VersionRowState.Uninstalling => Strings.StatusUninstalling, VersionRowState.Uninstalling => Strings.StatusUninstalling,
_ => string.Empty _ => string.Empty

View 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>

View 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);
}

View File

@@ -192,22 +192,21 @@
La sidebar a RowSpan=2 → s'étend du sous-topbar jusqu'au bas La sidebar a RowSpan=2 → s'étend du sous-topbar jusqu'au bas
de fenêtre. Le footer est confiné à la colonne content. de fenêtre. Le footer est confiné à la colonne content.
--> -->
<Grid Background="Black"> <Grid Background="{StaticResource Brush.Bg.Window}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="200" /> <ColumnDefinition Width="200" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions> </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é à la zone content (Row 1, Col 1) pour ne pas <!-- Background image : limité à la zone content (Row 2, Col 1) pour ne pas
passer derrière la sidebar ni le footer. ImageBrush avec AlignmentX=Right passer derrière la sidebar, le banner ou le footer. -->
+ AlignmentY=Bottom : si le ratio fenêtre force un crop, on rogne en <Rectangle Grid.Row="2" Grid.Column="1" IsHitTestVisible="False">
haut/à gauche — le logo ASTERION VR en bas-droite reste TOUJOURS visible. -->
<Rectangle Grid.Row="1" Grid.Column="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"
@@ -216,6 +215,9 @@
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).
Grid.ColumnSpan=2 → s'étend sur sidebar + content. --> Grid.ColumnSpan=2 → s'étend sur sidebar + content. -->
@@ -339,10 +341,56 @@
</Grid> </Grid>
</Border> </Border>
<!-- ============== Content area (Row 1, Col 1, swap par CurrentPage) ============== <!-- ============== Health banner (Row 1, Col 1 uniquement) ==============
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 Superpose plusieurs blocs (Library / Report / Documentation) gérés par
leur Visibility liée à CurrentPage. --> leur Visibility liée à CurrentPage. -->
<Grid Grid.Row="1" Grid.Column="1"> <Grid Grid.Row="2" Grid.Column="1">
<!-- ====================== LIBRARY PAGE ====================== --> <!-- ====================== LIBRARY PAGE ====================== -->
<Grid Visibility="{Binding IsLibrary, Converter={StaticResource BoolToVisibility}}"> <Grid Visibility="{Binding IsLibrary, Converter={StaticResource BoolToVisibility}}">
@@ -605,10 +653,11 @@
<!-- ============== END Content area ============== --> <!-- ============== END Content area ============== -->
<!-- ============== Left sidebar : nav buttons ============== <!-- ============== Left sidebar : nav buttons ==============
Row 1 + Row 2 (RowSpan=2) → la sidebar s'étend depuis le bas du Row 1 + RowSpan=3 → la sidebar s'étend du bas du top bar jusqu'au
top bar jusqu'en bas de la fenêtre, peu importe la visibilité du bas de la fenêtre, couvrant les 3 rows (health banner, content,
footer ou de la taille de la zone content. --> footer). Visuellement la sidebar reste le « chapeau de tout »
<Border Grid.Row="1" Grid.RowSpan="2" Grid.Column="0" côté gauche, peu importe ce qui se passe à droite. -->
<Border Grid.Row="1" Grid.RowSpan="3" Grid.Column="0"
Background="#0A0A0E" Background="#0A0A0E"
BorderBrush="{StaticResource Brush.Border}" BorderBrush="{StaticResource Brush.Border}"
BorderThickness="0,0,1,0"> BorderThickness="0,0,1,0">
@@ -641,9 +690,10 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Footer : Row 2, Col 1 → cantonné à la colonne content, sous la zone <!-- Footer : Row 3, Col 1 → cantonné à la colonne content, sous la zone
Library/Report/Doc. La sidebar (RowSpan=2) le contourne par sa colonne. --> Library/Report/Doc. La sidebar (RowSpan=2 sur rows 2-3) le contourne
<Border Grid.Row="2" Grid.Column="1" par sa colonne. -->
<Border Grid.Row="3" Grid.Column="1"
Background="{StaticResource Brush.Bg.Footer}" 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"

View File

@@ -230,6 +230,19 @@
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}" Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" /> FontFamily="Consolas" />
<!-- Connexions parallèles : tuning de la queue de fin de DL -->
<TextBlock Text="{x:Static loc:Strings.SettingsParallelSegments}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,16,0,4" />
<TextBox Text="{Binding ParallelSegments, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
Width="80" HorizontalAlignment="Left" />
<TextBlock Text="{x:Static loc:Strings.SettingsParallelSegmentsHint}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" FontStyle="Italic" Margin="0,4,0,0"
TextWrapping="Wrap" />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -525,6 +538,197 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Outil Doc (XAMPP htdocs deploy, mirror of Report) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsDocTool}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportHtdocs}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DocHtdocsRoot, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportFolder}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,12,0,4" />
<TextBox Text="{Binding DocFolderName, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<CheckBox IsChecked="{Binding DocAutoDeploy}"
Margin="0,12,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsDocAutoDeploy}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsDocMaxBackups}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding DocMaxBackups, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</Grid>
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding DocDeployStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsDocRedeploy}"
Command="{Binding RedeployDocCommand}"
IsEnabled="{Binding IsDeployingDoc, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsDocBackups}"
FontWeight="Bold" FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,16,0,6" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportNoBackups}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" FontStyle="Italic"
Visibility="{Binding HasDocBackups, Converter={StaticResource InverseBoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding DocBackups}"
Visibility="{Binding HasDocBackups, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#1A1A20"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding DateDisplay}"
Foreground="{StaticResource Brush.Text.Primary}"
FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="{Binding SizeDisplay}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" />
</StackPanel>
<TextBlock Grid.Column="1"
Text="{Binding FolderName}"
FontFamily="Consolas" FontSize="10"
Foreground="{StaticResource Brush.Text.Secondary}"
VerticalAlignment="Center" Margin="0,0,12,0" />
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsReportRevert}"
Command="{Binding RevertCommand}"
Padding="12,6" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Bandeau de santé système : liste éditable des checks -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsHealthChecks}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthAdd}"
Command="{Binding AddHealthCheckCommand}"
Padding="12,6" />
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsHealthChecksHint}"
FontSize="11" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,8,0,8" TextWrapping="Wrap" />
<TextBlock Text="{x:Static loc:Strings.SettingsHealthEmpty}"
FontSize="12" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0" TextWrapping="Wrap"
Visibility="{Binding HasHealthChecks, Converter={StaticResource InverseBoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding HealthChecks}"
Visibility="{Binding HasHealthChecks, Converter={StaticResource BoolToVisibility}}"
Margin="0,4,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#1A1A20"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding DisplayIcon}"
FontSize="20"
VerticalAlignment="Center"
Margin="2,0,12,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayName}"
Foreground="{StaticResource Brush.Text.Primary}"
FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="{Binding DisplayDetail}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11"
FontFamily="Consolas" />
</StackPanel>
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthEdit}"
Command="{Binding EditCommand}"
Margin="0,0,6,0"
Padding="12,6" />
<Button Grid.Column="3"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthDelete}"
Command="{Binding DeleteCommand}"
Padding="12,6" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Logs (path + open button) --> <!-- Logs (path + open button) -->
<Border Background="{StaticResource Brush.Bg.Card}" <Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"

View File

@@ -0,0 +1,211 @@
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))
{
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot);
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)");
}
var stagingTarget = target + ".new";
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
try
{
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");
}
}
}

View 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);
}

View File

@@ -176,7 +176,10 @@ public sealed class DownloadManager : IDownloadManager
} }
// 2. Choix single vs multi-segment // 2. Choix single vs multi-segment
var requestedSegments = Math.Clamp(_config.ParallelDownloadSegments, 1, 16); // 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 var useMultiSegment = requestedSegments > 1
&& job.ExpectedSize >= MultiSegmentMinSize && job.ExpectedSize >= MultiSegmentMinSize
&& (existing is null || existing.Segments.Count > 0); && (existing is null || existing.Segments.Count > 0);

View 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,
}

View 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.01.0, null si propriété non exposée par le device
OpenVRNative.EDeviceActivityLevel? ActivityLevel,
string? ErrorDetail);

View 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);
}

View 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();
}
}

View 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 &lt; 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);
}
}

View File

@@ -95,6 +95,7 @@ public static class Strings
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…"); public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…");
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…"); public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…");
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…"); public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…");
// ==================== ACTIONS ==================== // ==================== ACTIONS ====================
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل"); public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
@@ -177,6 +178,21 @@ public static class Strings
$"تعذر تحميل {url}\n\n{detail}\n\nتأكد من تشغيل XAMPP وأن العنوان صحيح (الإعدادات → متقدم)." $"تعذر تحميل {url}\n\n{detail}\n\nتأكد من تشغيل XAMPP وأن العنوان صحيح (الإعدادات → متقدم)."
); );
public static string SettingsParallelSegments => T(
"Connexions parallèles pour les téléchargements (1-32, défaut 16)",
"Parallel connections for downloads (1-32, default 16)",
"下载的并行连接数 (1-32默认 16)",
"การเชื่อมต่อแบบขนานสำหรับการดาวน์โหลด (1-32, ค่าเริ่มต้น 16)",
"اتصالات متوازية للتنزيلات (1-32، الافتراضي 16)"
);
public static string SettingsParallelSegmentsHint => T(
"Plus de connexions = débit pic plus élevé et queue de fin de DL plus courte. Au-delà de 16 le serveur peut throttler.",
"More connections = higher peak throughput and shorter end-of-DL tail. Above 16 the server may throttle.",
"更多连接 = 更高的峰值吞吐量和更短的下载尾部。超过 16 服务器可能会限速。",
"การเชื่อมต่อมากขึ้น = ปริมาณงานสูงสุดมากขึ้นและส่วนท้ายของการดาวน์โหลดสั้นลง สูงกว่า 16 เซิร์ฟเวอร์อาจจำกัด",
"اتصالات أكثر = إنتاجية ذروة أعلى وذيل تنزيل أقصر. فوق 16 قد يقيّد الخادم."
);
public static string SettingsAdvanced => T( public static string SettingsAdvanced => T(
"▸ PARAMÈTRES AVANCÉS (serveur, installation, base de données, logs)", "▸ PARAMÈTRES AVANCÉS (serveur, installation, base de données, logs)",
"▸ ADVANCED SETTINGS (server, installation, database, logs)", "▸ ADVANCED SETTINGS (server, installation, database, logs)",
@@ -515,6 +531,42 @@ public static class Strings
); );
public static string SettingsReportRedeploy => T("📂 Re-déployer le Report maintenant", "📂 Re-deploy Report now", "📂 立即重新部署报告", "📂 ปรับใช้รายงานใหม่เดี๋ยวนี้", "📂 إعادة نشر التقرير الآن"); public static string SettingsReportRedeploy => T("📂 Re-déployer le Report maintenant", "📂 Re-deploy Report now", "📂 立即重新部署报告", "📂 ปรับใช้รายงานใหม่เดี๋ยวนี้", "📂 إعادة نشر التقرير الآن");
// ---- Doc tool (mêmes mécanique que Report, autres libellés) ----
public static string StatusDeployingDoc(string version) => T(
$"Déploiement de la Documentation v{version}…",
$"Deploying Documentation for v{version}…",
$"正在部署 v{version} 的文档…",
$"กำลังปรับใช้เอกสาร v{version}…",
$"جارٍ نشر التوثيق v{version}…"
);
public static string ProgressDocDeploy(int done, int total, string filename) => T(
$"📖 Déploiement Doc : {done}/{total} — {filename}",
$"📖 Deploying Doc: {done}/{total} — {filename}",
$"📖 部署文档:{done}/{total} — {filename}",
$"📖 ปรับใช้เอกสาร: {done}/{total} — {filename}",
$"📖 نشر التوثيق: {done}/{total} — {filename}"
);
public static string MsgBoxDocDeployFailed => T("Déploiement Doc échoué", "Doc deploy failed", "文档部署失败", "การปรับใช้เอกสารล้มเหลว", "فشل نشر التوثيق");
public static string MsgDocDeployFailed(string detail) => T(
$"Le déploiement de la Documentation a échoué :\n\n{detail}\n\nLa version PROSERVE est installée mais l'onglet Documentation pourrait être désynchronisé. Tu peux retenter via Paramètres → Avancés → Re-déployer la Doc.",
$"Documentation deploy failed:\n\n{detail}\n\nThe PROSERVE version is installed but the Documentation tab might be out of sync. You can retry via Settings → Advanced → Re-deploy Doc.",
$"文档部署失败:\n\n{detail}\n\nPROSERVE 版本已安装,但文档选项卡可能不同步。您可以通过 设置 → 高级 → 重新部署文档 重试。",
$"การปรับใช้เอกสารล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วแต่แท็บเอกสารอาจไม่ซิงค์ คุณสามารถลองใหม่ผ่าน ตั้งค่า → ขั้นสูง → ปรับใช้เอกสารใหม่",
$"فشل نشر التوثيق:\n\n{detail}\n\nنسخة PROSERVE مثبتة لكن علامة التبويب التوثيق قد لا تكون متزامنة. يمكنك إعادة المحاولة عبر الإعدادات → متقدم → إعادة نشر التوثيق."
);
public static string SettingsDocTool => T("OUTIL DOCUMENTATION (XAMPP htdocs)", "DOCUMENTATION TOOL (XAMPP htdocs)", "文档工具 (XAMPP htdocs)", "เครื่องมือเอกสาร (XAMPP htdocs)", "أداة التوثيق (XAMPP htdocs)");
public static string SettingsDocAutoDeploy => T(
"Déployer automatiquement la Documentation à l'install d'une nouvelle version",
"Automatically deploy the Documentation when installing a new version",
"安装新版本时自动部署文档",
"ปรับใช้เอกสารโดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่",
"نشر التوثيق تلقائياً عند تثبيت نسخة جديدة"
);
public static string SettingsDocRedeploy => T("📖 Re-déployer la Doc maintenant", "📖 Re-deploy Doc now", "📖 立即重新部署文档", "📖 ปรับใช้เอกสารใหม่เดี๋ยวนี้", "📖 إعادة نشر التوثيق الآن");
public static string SettingsDocBackups => SettingsReportBackups;
public static string SettingsDocMaxBackups => SettingsReportMaxBackups;
// ---- Backups Report tool ---- // ---- Backups Report tool ----
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة"); public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية"); public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");
@@ -894,6 +946,124 @@ public static class Strings
$"تاريخ الإصدار: {date} • {size}" $"تاريخ الإصدار: {date} • {size}"
); );
// ==================== HEALTH BANNER ====================
public static string SettingsHealthChecks => T(
"BANDEAU DE SANTÉ SYSTÈME",
"SYSTEM HEALTH BANNER",
"系统健康状态栏",
"แถบสถานะระบบ",
"شريط حالة النظام"
);
public static string SettingsHealthChecksHint => T(
"Pings et processus surveillés sous la top bar (Library uniquement). Édité ici, sauvegardé dans config.json — survit à un auto-update.",
"Pings and processes monitored below the top bar (Library only). Edited here, saved to config.json — survives auto-updates.",
"在顶部栏下监控的 Ping 和进程(仅库视图)。在此编辑,保存到 config.json — 在自动更新后保留。",
"Ping และโปรเซสที่ติดตามใต้แถบบนสุด (เฉพาะ Library) แก้ไขที่นี่ บันทึกใน config.json — คงอยู่หลังการอัปเดตอัตโนมัติ",
"Ping والعمليات المراقَبة أسفل الشريط العلوي (المكتبة فقط). يُحرَّر هنا، ويُحفَظ في config.json — يبقى بعد التحديث التلقائي."
);
public static string SettingsHealthAdd => T(" Ajouter", " Add", " 添加", " เพิ่ม", " إضافة");
public static string SettingsHealthEdit => T("✎ Éditer", "✎ Edit", "✎ 编辑", "✎ แก้ไข", "✎ تحرير");
public static string SettingsHealthDelete => T("🗑 Supprimer", "🗑 Delete", "🗑 删除", "🗑 ลบ", "🗑 حذف");
public static string SettingsHealthEmpty => T(
"Aucun check configuré. Le bandeau ne s'affichera pas tant qu'aucune entrée n'aura été ajoutée.",
"No checks configured. The banner stays hidden until at least one entry is added.",
"未配置任何检查。在添加至少一项之前,状态栏将保持隐藏。",
"ยังไม่มีการตรวจสอบที่ตั้งค่าไว้ แถบจะถูกซ่อนจนกว่าจะมีการเพิ่มอย่างน้อยหนึ่งรายการ",
"لم يُهيَّأ أي فحص. سيبقى الشريط مخفياً حتى تتم إضافة إدخال واحد على الأقل."
);
public static string SettingsHealthDeleteConfirm(string name) => T(
$"Supprimer le check « {name} » ?",
$"Delete check « {name} »?",
$"删除检查 \"{name}\"",
$"ลบการตรวจสอบ « {name} »?",
$"حذف الفحص « {name} »؟"
);
public static string HealthEditorTitle => T("Éditer le check", "Edit check", "编辑检查", "แก้ไขการตรวจสอบ", "تحرير الفحص");
public static string HealthEditorTitleNew => T("Nouveau check", "New check", "新建检查", "การตรวจสอบใหม่", "فحص جديد");
public static string HealthEditorName => T("NOM", "NAME", "名称", "ชื่อ", "الاسم");
public static string HealthEditorNameHint => T(
"Libellé court affiché dans la pill du bandeau, ex. « SteamVR ».",
"Short label shown in the banner pill, e.g. \"SteamVR\".",
"状态栏胶囊中显示的简短标签,例如 \"SteamVR\"。",
"ชื่อย่อที่แสดงในป้ายของแถบ เช่น \"SteamVR\"",
"تسمية قصيرة تظهر في الشريط، مثل \"SteamVR\"."
);
public static string HealthEditorKind => T("TYPE DE VÉRIFICATION", "CHECK TYPE", "检查类型", "ประเภทการตรวจสอบ", "نوع الفحص");
public static string HealthEditorKindProcess => T("Processus (Windows)", "Process (Windows)", "进程 (Windows)", "โปรเซส (Windows)", "عملية (Windows)");
public static string HealthEditorKindPing => T("Ping (réseau ICMP)", "Ping (ICMP network)", "Ping (ICMP 网络)", "Ping (เครือข่าย ICMP)", "Ping (شبكة ICMP)");
public static string HealthEditorKindVrDevice => T(
"Appareil SteamVR",
"SteamVR device",
"SteamVR 设备",
"อุปกรณ์ SteamVR",
"جهاز SteamVR"
);
public static string HealthEditorTargetVrDevice => T("APPAREIL VR", "VR DEVICE", "VR 设备", "อุปกรณ์ VR", "جهاز VR");
public static string HealthEditorTargetVrDeviceHint => T(
"Phase 1 : détecte la présence de SteamVR et d'un HMD branché (vert si oui, rouge sinon). La batterie + l'info par-appareil arrive en Phase 1.5 via un sous-process séparé pour être robuste aux crashs natifs OpenVR.",
"Phase 1: detects SteamVR running with an HMD plugged in (green if yes, red otherwise). Battery + per-device info coming in Phase 1.5 via a separate child process to be robust against OpenVR native crashes.",
"第 1 阶段:检测 SteamVR 是否运行以及是否插入了 HMD是则绿色否则红色。电池 + 每个设备的信息将在第 1.5 阶段通过单独的子进程提供,以增强对 OpenVR 原生崩溃的鲁棒性。",
"เฟส 1: ตรวจจับว่า SteamVR กำลังทำงานและมี HMD เชื่อมต่ออยู่หรือไม่ (เขียวถ้าใช่ แดงถ้าไม่) ข้อมูลแบตเตอรี่ + ต่ออุปกรณ์จะมาในเฟส 1.5 ผ่านโปรเซสลูกแยกเพื่อความทนทานต่อข้อผิดพลาดเนทีฟ OpenVR",
"المرحلة 1: تكتشف تشغيل SteamVR مع توصيل HMD (أخضر إذا نعم، أحمر خلاف ذلك). البطارية + المعلومات لكل جهاز ستأتي في المرحلة 1.5 عبر عملية فرعية منفصلة لتكون متينة ضد الأعطال الأصلية OpenVR."
);
public static string HealthEditorTargetProcess => T("CIBLE — NOM DE PROCESSUS", "TARGET — PROCESS NAME", "目标 — 进程名", "เป้าหมาย — ชื่อโปรเซส", "الهدف — اسم العملية");
public static string HealthEditorTargetPing => T("CIBLE — IP OU HOSTNAME", "TARGET — IP OR HOSTNAME", "目标 — IP 或主机名", "เป้าหมาย — IP หรือชื่อโฮสต์", "الهدف — IP أو اسم المضيف");
public static string HealthEditorTargetProcessHint => T(
"Nom de l'exécutable sans .exe, ex. « vrserver » ou « HtcConnectionUtility ». Insensible à la casse.",
"Executable name without .exe, e.g. \"vrserver\" or \"HtcConnectionUtility\". Case-insensitive.",
"可执行文件名(不含 .exe例如 \"vrserver\" 或 \"HtcConnectionUtility\"。不区分大小写。",
"ชื่อไฟล์ปฏิบัติการโดยไม่มี .exe เช่น \"vrserver\" หรือ \"HtcConnectionUtility\" ไม่คำนึงถึงตัวพิมพ์",
"اسم الملف التنفيذي بدون .exe، مثل \"vrserver\" أو \"HtcConnectionUtility\". غير حساس لحالة الأحرف."
);
public static string HealthEditorTargetPingHint => T(
"Adresse IP (ex. 192.168.1.42) ou hostname résolvable. Vide = check ignoré (statut Inconnu).",
"IP address (e.g. 192.168.1.42) or resolvable hostname. Empty = check skipped (Unknown status).",
"IP 地址(例如 192.168.1.42)或可解析的主机名。空 = 跳过检查(未知状态)。",
"ที่อยู่ IP (เช่น 192.168.1.42) หรือชื่อโฮสต์ที่แก้ได้ ว่างเปล่า = ข้ามการตรวจสอบ (สถานะไม่ทราบ)",
"عنوان IP (مثل 192.168.1.42) أو اسم مضيف قابل للحل. فارغ = تخطّي الفحص (الحالة غير معروفة)."
);
public static string HealthEditorRefresh => T("INTERVALLE DE RAFRAÎCHISSEMENT", "REFRESH INTERVAL", "刷新间隔", "ช่วงเวลารีเฟรช", "فاصل التحديث");
public static string HealthEditorRefreshHint => T(
"Délai entre 2 vérifications, en millisecondes. Process ~1 ms (1000 OK) ; Ping = round-trip réseau (5000-10000 conseillé). Min 200 ms.",
"Delay between 2 checks, in milliseconds. Process ~1 ms (1000 OK); Ping = network round-trip (5000-10000 recommended). Min 200 ms.",
"两次检查之间的延迟(毫秒)。进程约 1 毫秒1000 即可Ping = 网络往返(建议 5000-10000。最小 200 毫秒。",
"ระยะเวลาระหว่าง 2 การตรวจสอบเป็นมิลลิวินาที โปรเซส ~1 ms (1000 OK); Ping = round-trip เครือข่าย (แนะนำ 5000-10000). ขั้นต่ำ 200 ms.",
"التأخير بين فحصين بالمللي ثانية. عملية ~1 ms (1000 جيدة)؛ Ping = ذهاب وعودة الشبكة (يوصى 5000-10000). الحد الأدنى 200 ms."
);
public static string HealthEditorIcon => T("PICTOGRAMME", "ICON", "图标", "ไอคอน", "أيقونة");
public static string HealthEditorIconHint => T(
"Affiché à gauche du nom dans la pill. Choisis-en un dans la liste.",
"Displayed to the left of the name in the pill. Pick one from the list.",
"显示在胶囊中名称的左侧。从列表中选择一个。",
"แสดงทางซ้ายของชื่อในป้าย เลือกหนึ่งจากรายการ",
"تُعرض على يسار الاسم في الشارة. اختر واحدةً من القائمة."
);
public static string HealthEditorPingAdvanced => T(
"Avancé — seuils Ping",
"Advanced — Ping thresholds",
"高级 — Ping 阈值",
"ขั้นสูง — เกณฑ์ Ping",
"متقدم — حدود Ping"
);
public static string HealthEditorPingWarn => T("Warning ms — RTT au-dessus = orange", "Warning ms — RTT above = amber", "警告 ms — 高于此值 = 橙色", "Warning ms — RTT เกินค่านี้ = ส้ม", "تحذير ms — RTT فوق ذلك = برتقالي");
public static string HealthEditorPingError => T("Error ms — RTT au-dessus = rouge", "Error ms — RTT above = red", "错误 ms — 高于此值 = 红色", "Error ms — RTT เกินค่านี้ = แดง", "خطأ ms — RTT فوق ذلك = أحمر");
public static string HealthEditorPingTimeout => T("Timeout ms — abandon de la requête", "Timeout ms — abort the request", "超时 ms — 中止请求", "Timeout ms — ยกเลิกคำขอ", "مهلة ms — إلغاء الطلب");
public static string HealthEditorErrorName => T(
"Le nom est requis.",
"Name is required.",
"需要名称。",
"ต้องระบุชื่อ",
"الاسم مطلوب."
);
public static string HealthEditorErrorRefresh => T(
"Intervalle invalide. Saisis un entier ≥ 200 (millisecondes).",
"Invalid interval. Enter an integer ≥ 200 (milliseconds).",
"间隔无效。请输入 ≥ 200 的整数(毫秒)。",
"ช่วงเวลาไม่ถูกต้อง ใส่จำนวนเต็ม ≥ 200 (มิลลิวินาที)",
"فاصل غير صالح. أدخل عدداً صحيحاً ≥ 200 (مللي ثانية)."
);
// ==================== COPYRIGHT ==================== // ==================== COPYRIGHT ====================
public static string Copyright => T( public static string Copyright => T(
"© 2026 ASTERION VR — Tous droits réservés", "© 2026 ASTERION VR — Tous droits réservés",

View File

@@ -5,6 +5,8 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<!-- unsafe requis pour le déréférencement de la vtable OpenVR (Health/OpenVR/OpenVRNative.cs) -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -16,9 +16,13 @@ public sealed class LocalConfig
/// <summary> /// <summary>
/// Nombre de connexions HTTP parallèles utilisées pour télécharger un build. /// Nombre de connexions HTTP parallèles utilisées pour télécharger un build.
/// 1 = comportement legacy single-connection. 4-8 contourne le throttling /// 1 = comportement legacy single-connection. 4-8 contourne le throttling
/// per-connection d'Apache/OVH mutualisé. 8 est un bon défaut. /// per-connection d'Apache/OVH mutualisé. 16 = défaut depuis v0.19 pour
/// raccourcir la « queue » en fin de DL : avec 8 segments sur 14 Go, le
/// dernier segment doit finir seul ~1.75 Go (visible comme un effondrement
/// du débit en fin de course) ; avec 16, c'est ~875 Mo. Push à 24/32 si
/// ton serveur l'accepte (OVH mutualisé tient bien jusqu'à 16-24).
/// </summary> /// </summary>
public int ParallelDownloadSegments { get; set; } = 8; public int ParallelDownloadSegments { get; set; } = 16;
/// <summary> /// <summary>
/// Si false, ignore la vérification SHA-256 même si le manifest l'exige. /// Si false, ignore la vérification SHA-256 même si le manifest l'exige.
@@ -36,12 +40,11 @@ public sealed class LocalConfig
public string ReportUrl { get; set; } = "http://localhost/ProserveReport/"; public string ReportUrl { get; set; } = "http://localhost/ProserveReport/";
/// <summary> /// <summary>
/// URL de la documentation locale (onglet "Documentation"). Vide par défaut : /// URL de la documentation locale (onglet "Documentation"). Pointe par défaut
/// l'utilisateur peut pointer vers une URL locale ou web. Si vide, l'onglet /// vers l'install standard ASTERION dans XAMPP. Surchargeable dans Settings →
/// affiche un placeholder avec les liens vers les fichiers .pptx du dossier /// Avancés. Si vide, l'onglet affiche un placeholder.
/// d'install du launcher.
/// </summary> /// </summary>
public string DocumentationUrl { get; set; } = string.Empty; public string DocumentationUrl { get; set; } = "http://localhost/ProserveDoc/";
public LicenseConfig License { get; set; } = new(); public LicenseConfig License { get; set; } = new();
@@ -53,6 +56,22 @@ public sealed class LocalConfig
/// </summary> /// </summary>
public ReportToolConfig ReportTool { get; set; } = new(); public ReportToolConfig ReportTool { get; set; } = new();
/// <summary>
/// Configuration du déploiement de l'outil Documentation (page web locale
/// embarquée dans l'onglet "Documentation"). Même mécanisme que ReportTool :
/// copie de <c>_doc/</c> bundled dans le ZIP PROSERVE vers
/// <c>{HtdocsRoot}\{FolderName}</c> avec backups horodatés et revert.
/// </summary>
public DocToolConfig DocTool { get; set; } = new();
/// <summary>
/// Surveillance d'état système affichée en bandeau sous la top bar
/// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…).
/// Chaque check a un statut Vert (OK) / Orange (warn, ex. ping élevé) / Rouge
/// (KO). Tooltip détaillé au survol.
/// </summary>
public HealthChecksConfig HealthChecks { get; set; } = new();
/// <summary> /// <summary>
/// Connexion à la base MySQL locale (XAMPP) utilisée par PROSERVE pour ses /// Connexion à la base MySQL locale (XAMPP) utilisée par PROSERVE pour ses
/// statistiques. Le launcher s'en sert pour appliquer les migrations bundled /// statistiques. Le launcher s'en sert pour appliquer les migrations bundled
@@ -118,6 +137,99 @@ public sealed class ReportToolConfig
public int MaxBackups { get; set; } = 3; public int MaxBackups { get; set; } = 3;
} }
/// <summary>
/// Mêmes paramètres que <see cref="ReportToolConfig"/> mais pour la
/// documentation locale (sous-dossier <c>_doc/</c> du ZIP, déployé vers
/// <c>{HtdocsRoot}\ProserveDoc</c> par défaut).
/// </summary>
public sealed class DocToolConfig
{
public string HtdocsRoot { get; set; } = @"C:\xampp\htdocs";
public string FolderName { get; set; } = "ProserveDoc";
public bool AutoDeploy { get; set; } = true;
public int MaxBackups { get; set; } = 3;
}
public sealed class HealthChecksConfig
{
/// <summary>Période de re-vérification, en secondes. 0 = désactivé.</summary>
public int RefreshIntervalSeconds { get; set; } = 10;
/// <summary>
/// Liste des checks à effectuer. Defaults = exemples typiques setup VR
/// (SteamVR + Vive Business Streaming + ping casque). L'utilisateur édite
/// la liste via <c>%LocalAppData%\PSLauncher\config.json</c> pour ajuster
/// les noms de processus, IPs et seuils ; un éditeur Settings est prévu
/// en v2 si le besoin est récurrent.
/// </summary>
public List<HealthCheckEntry> Checks { get; set; } = new()
{
new HealthCheckEntry
{
Name = "SteamVR",
Icon = "🎮",
Kind = "Process",
Target = "vrserver",
},
new HealthCheckEntry
{
Name = "Vive Business Streaming",
Icon = "📡",
Kind = "Process",
Target = "HtcConnectionUtility",
},
// PAS de check « casque connecté » par défaut.
//
// Pourquoi : avec Vive Business Streaming dans la pile, le driver Vive
// ment à SteamVR — il prétend qu'un HMD est présent même quand la
// session Wi-Fi avec le casque physique est tombée. Donc tous les
// signaux qui passent par SteamVR (VR_IsHmdPresent, vtable IVRSystem,
// logs vrserver) sont contaminés par ce mensonge et ne reflètent pas
// l'état réel du casque.
//
// À traiter en Phase 2 via un signal authoritative qui ne passe PAS
// par SteamVR (pistes brainstormées : connexions TCP/UDP de
// HtcConnectionUtility via GetExtendedTcpTable, endpoint local de
// Vive Console, compteurs IO du process, ARP table avec OUIs HTC,
// logs Vive Streaming directs). Ajout d'un Kind dédié à ce moment-là.
};
}
public sealed class HealthCheckEntry
{
/// <summary>Libellé affiché dans le bandeau, ex. "SteamVR".</summary>
public string Name { get; set; } = "";
/// <summary>Pictogramme emoji affiché à côté du nom, ex. "🎮", "📡", "🥽".</summary>
public string Icon { get; set; } = "🔌";
/// <summary>"Process" ou "Ping" (case-insensitive).</summary>
public string Kind { get; set; } = "Process";
/// <summary>
/// Selon Kind : nom du processus (sans .exe) pour Process,
/// IP/hostname pour Ping. Vide = check ignoré (statut Unknown).
/// </summary>
public string Target { get; set; } = "";
/// <summary>
/// Période entre 2 vérifications de CE check, en millisecondes. Indépendant
/// pour chaque entry car un Process check est rapide (~1ms) et peut tourner
/// toutes les secondes, alors qu'un Ping coûte une requête réseau et peut
/// tourner toutes les 5-10 s. Default 5000 ms.
/// </summary>
public int RefreshIntervalMs { get; set; } = 5000;
/// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms.</summary>
public int? PingWarnMs { get; set; } = 50;
/// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Error (Rouge). Default 200 ms.</summary>
public int? PingErrorMs { get; set; } = 200;
/// <summary>Pour Ping : timeout de la requête ICMP. Default 1 s.</summary>
public int? PingTimeoutMs { get; set; } = 1000;
}
public sealed class LicenseConfig public sealed class LicenseConfig
{ {
public string? EncryptedKey { get; set; } public string? EncryptedKey { get; set; }

View File

@@ -8,7 +8,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyName>PS_Launcher.Updater</AssemblyName> <AssemblyName>PS_Launcher.Updater</AssemblyName>
<RootNamespace>PSLauncher.Updater</RootNamespace> <RootNamespace>PSLauncher.Updater</RootNamespace>
<Version>0.16.0</Version> <Version>0.21.0</Version>
<ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon> <ApplicationIcon>..\PSLauncher.App\Resources\favicon.ico</ApplicationIcon>
<Company>ASTERION VR</Company> <Company>ASTERION VR</Company>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright> <Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>