Changement majeur du modèle de channels en réponse au feedback :
« c'est trop compliqué. Il faudrait juste pouvoir tout laisser dans
builds, et taguer une version dans un canal ou un autre voir plusieurs
canaux. ». Le précédent design (1 fichier manifest par channel +
sous-dossier builds/{channel}/) imposait une duplication de structure
et empêchait une version d'être visible sur plusieurs channels à la fois.
NOUVEAU MODÈLE :
- 1 seul fichier manifest/versions.json
- 1 seul dossier builds/ flat (les ZIPs ont des noms libres pour éviter
les collisions homonymes — proserve-1.5.3.zip, proserve-1.5.3-police.zip…)
- Chaque entry de version a un champ `channels: ["default", "police"]`
qui dit qui peut la voir
- Sémantique additive : un client sur channel "police" voit les versions
taggées "default" + celles taggées "police". "default" = public.
- Une version peut être taggée sur plusieurs channels en une fois.
NOUVEAU REGISTRE channels.json :
Le registre des channels (avec name + label + description) vit dans
manifest/channels.json. Le channel "default" est implicite et ne peut
pas être supprimé. Nouvelle page admin Channels (CRUD) pour gérer la
liste, avec garde-fou anti-suppression : compte les versions taggées
et les licenses attribuées avant d'autoriser la suppression.
SERVEUR :
- api/routes/Manifest.php : reçoit ?channel=X depuis la license,
filtre versions où channels[] contient X ou "default", re-signe avec
la clé privée Ed25519 à la volée. Coût ~1ms par requête.
- admin/versions.php : refactor — plus de session "channel actif", plus
de switcher en haut, plus de prefix builds/{channel}/. Add form a
des checkboxes channels[]. Nouveau bouton "Channels" par-row pour
retager.
- admin/licenses.php : dropdown channel alimenté depuis channels.json
au lieu de scanner les fichiers manifest.
- tools/SignManifest.php : revert du constructeur channel-aware,
toujours sur versions.json + builds/ flat.
CLIENT :
- VersionManifest.Channels (List<string>) ajouté pour debug, mais le
filtrage est server-side donc le client n'a rien à faire de ce champ
côté UX.
- L'envoi du ?channel=X depuis la license signée fonctionne déjà
depuis v0.26.0, pas de changement nécessaire.
MIGRATION :
Les fichiers manifest/versions-{X}.json créés en v0.26.0 deviennent
inutiles. Si tu en avais (test "police" par exemple), copie les
versions concernées dans versions.json et tague-les avec les bons
channels via la nouvelle UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DEUX features liées :
1. CHANNELS : chaque license peut être attribuée à un manifest distinct
(« channel »). Permet de servir des versions différentes selon le
client. Le serveur lit ?channel=X et sert manifest/versions-{X}.json
avec fallback transparent sur versions.json. NULL = default.
2. BÊTA : nouveau flag isBeta + betaNotes par version dans le manifest.
Visible uniquement par les licenses avec can_see_betas=1. Affichage
d'une pill orange « BÊTA » sur la row + tooltip avec les notes pour
les testeurs. Les installations locales déjà présentes restent
visibles même si l'accès BÊTA est retiré ensuite (on n'efface pas
le disque du client).
DB :
002_channel_betas.sql ajoute channel + can_see_betas sur licenses.
Idempotent (ALTER TABLE IF NOT EXISTS), zero data migration.
Serveur PHP :
- ValidateLicense.php signe channel + canSeeBetas dans la réponse
(ordre des clés CRITIQUE pour matcher le canonical client).
- Manifest.php : whitelist regex anti-traversal sur ?channel=, fallback
silencieux sur versions.json si channel inconnu (évite leak de la
liste de channels par probing).
- SignManifest.php prend un channel optionnel → l'admin peut signer
chaque manifest indépendamment.
- admin/licenses.php : dropdown channel + checkbox bêta sur create,
bouton détails repliable par-row pour edit.
- admin/versions.php : channel switcher en tête, badge BÊTA sur chaque
row, dialog repliable « Bêta » avec checkbox + notes des testeurs.
Client C# :
- License.Channel + License.CanSeeBetas (dans le canonical signé).
- VersionManifest.IsBeta + BetaNotes.
- ManifestService prend un channelProvider via DI, lu depuis license
cachée à chaque fetch (lazy, pas de circular dep).
- MainViewModel.RebuildList filtre les versions IsBeta si !CanSeeBetas
(mais conserve les installées locales — on ne retire pas l'accès
rétroactivement à ce qui est déjà sur disque).
- VersionRowViewModel : props IsBeta / BetaNotes / BetaTooltip.
- MainWindow.xaml : pill orange à côté du n° version pour le featured
et les rows compactes, tooltip dynamique avec les notes testeurs.
Backward compat signature :
Anciennes licenses cachées (signées sans channel/canSeeBetas) sont
toujours validées via un fallback canonical legacy dans VerifySignature.
Sans ce fallback, le passage à v0.26 invaliderait toutes les caches
hors-ligne et bloquerait les users en mobilité.
Migration côté admin : jouer 002_channel_betas.sql sur la base, déployer
les fichiers PHP, créer manifest/versions-{channel}.json pour les
nouveaux channels (l'admin versions.php propose un input « Créer/utiliser
un nouveau channel »). Les licenses existantes restent en channel=NULL
= default = comportement actuel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fonctionne entièrement offline si un PC du LAN a fetché OVH récemment :
les autres PCs récupèrent le manifest + les ZIPs depuis lui sans Internet.
Discovery des peers quasi-instantanée au cold start, fail-fast partout sur
les fetch OVH pour ne pas bloquer la UI.
== Manifest fallback offline ==
- IManifestCache + ManifestCache (Core/Manifests/) : cache disque du dernier
manifest réussi (JSON brut byte-for-byte pour préserver la signature Ed25519).
Path : %LocalAppData%\PSLauncher\manifest-cache.json
- IPeerManifestFetcher + PeerManifestFetcher (Core/Lan/) : récupère le
manifest brut depuis un peer LAN via GET /v1/manifest. Probe parallèle
avec timeout court par peer.
- LanCacheServer : nouvelle route GET /v1/manifest qui sert le cache disque
byte-for-byte aux clients sans Internet.
- ManifestService.FetchAsync : online check d'abord (ping ICMP 8.8.8.8 +
fallback TCP connect au serveur OVH si firewall bloque ICMP, timeout
800ms × 2). Si online → OVH only (canonique, voit toutes les versions).
Si offline → peer LAN puis cache disque local.
- ManifestSource enum exposé via IManifestService.LastSource → call sites
peuvent prendre des décisions éclairées (skip release notes, badge UI).
== Discovery active des peers (cold start instantané) ==
- LanCacheServer : nouvelle route GET /v1/info qui retourne {host, port,
versions} (même payload que les beacons UDP).
- LanDiscoveryService : bootstrap au démarrage qui probe en parallèle tous
les peers connus (cache disque) + manuels (config) via /v1/info, timeout
1s par peer. Les peers qui répondent sont injectés dans _peers avec
lastSeen=now → apparaissent immédiatement dans ListDiscovered() et donc
dans la sidebar + PeerSourceResolver. Plus besoin d'attendre les 15s du
prochain beacon UDP.
- ILanDiscoveryService.ListKnown() exposé pour que les fetchers puissent
utiliser le cache disque comme seed.
- LocalConfig.LanCache.ManualPeerUrls : default ["http://10.0.4.100:47623"]
(IP fixe du serveur ASTERION dans toutes les salles VR).
== Timeouts courts sur tous les fetch OVH (fail-fast offline) ==
- ManifestService : 3s pour le manifest fetch (vs 100s par défaut)
- LicenseService.ValidateAsync : 3s (résolvait le délai de ~15s pendant
CheckForUpdates en offline — HttpClient global a Timeout=Infinite,
sans CTS court ça hangait jusqu'à l'abandon SYN TCP par l'OS)
- LicenseService.GetSignedDownloadUrlAsync : 5s
- ManifestService.FetchReleaseNotesAsync : 5s
== UI ==
- StatusMessage post-Check Updates suffixé avec la source du manifest :
"🌐 OVH (en ligne)" / "📡 Peer LAN (hors ligne)" / "💾 Cache local (hors ligne)"
- InstallVersionAsync : skip le fetch release notes si manifest source
!= OVH → dialog s'ouvre instantanément avec "indisponible" au lieu
d'attendre 5s de timeout.
== Latences attendues ==
| Scénario | Avant | Après |
|---|---|---|
| Vérifier MAJ online | ~5s | ~500ms |
| Vérifier MAJ offline | ~15-20s | ~5s |
| Cold start discovery (1 peer connu) | jusqu'à 15s | ~200ms |
| Click Install offline | ~5s release notes timeout | instantané |
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Feature : sur un site multi-PCs (salle VR), un PC peut servir les ZIPs PROSERVE
qu'il a déjà téléchargés aux autres PCs du LAN au lieu de les forcer à re-DL
14 Go depuis OVH. Auto-discovery par UDP broadcast, fallback transparent OVH
si aucun peer ne répond, intégrité préservée par SHA-256 du manifest signé Ed25519.
Architecture (src/PSLauncher.Core/Lan/) :
- ZipCacheStore : cache local des ZIPs téléchargés (sidecar SHA-256 pour servir
sans re-hash). Pruning auto au top-N par mtime, protège les versions encore
installées. Remplace le `File.Delete(zipPath)` post-install historique.
- LanCacheServer : serveur HTTP local sur TcpListener (PAS HttpListener — il
exigeait une URL ACL admin qui aurait obligé à coder un netsh dans l'installer).
Routes GET /v1/has/{ver} (probe) + GET /v1/zip/{ver} (Range support manuel,
~80 LOC de parsing HTTP). Filtre RFC1918/link-local sur RemoteEndPoint avant
toute IO, regex stricte sur version (anti-path-traversal), SemaphoreSlim(4)
pour rate-limiter à 4 DL concurrents.
- LanDiscoveryService : émet beacon UDP (255.255.255.255:47624, JSON avec host+
port+versions) toutes les 15 s en mode serveur ; écoute en mode client/serveur
avec TTL 60 s sur les peers. Pure .NET, zero NuGet (pas de mDNS deps).
- PeerSourceResolver : avant chaque DL, probe les peers (auto-découverts +
manuels) avec timeout 1 s × max 5 peers. Vérifie size + SHA-256 contre le
manifest avant de retourner une URL peer. Si null → fallback OVH transparent.
Modifs flow d'install (MainViewModel) :
- Avant GetSignedDownloadUrlAsync, tente PeerSourceResolver. Si peer trouvé,
utilise son URL directement (le DownloadManager multi-segment marche tel quel).
- RefreshUrlAsync handler : pour les peers, retourne la même URL (pas d'expiration
HMAC). Pour OVH, refresh du HMAC inchangé.
- Post-install : remplace File.Delete(zipPath) par ZipCacheStore.Promote + Prune.
Sidebar info (visible en permanence dans le coin bas-gauche) :
- Lignes "Serveur ON/KO/—", "Client ON/—", "Peers N" — refresh auto toutes les
10 s via DispatcherTimer. Diagnostic instantané sans naviguer dans Settings.
Footer pendant le DL : préfixe différencié pour rendre la source évidente —
"📡 LAN 10.0.0.5 • v… : … Mo/s" vs "⬇ v… : … Mo/s" (OVH).
Settings → Cache LAN P2P : checkboxes ServerEnabled/ClientEnabled, ports HTTP/UDP,
MaxCachedVersions, statut serveur live, liste peers découverts, champ multi-line
peers manuels (override/fallback de l'auto-discovery). Toggle des flags →
prompt "Restart launcher requis" car les hosted services lisent leur config au
boot uniquement.
Fix critique au passage : App.OnStartup appelait .Build() mais JAMAIS .StartAsync()
sur l'IHost — du coup AUCUN HostedService n'a jamais tourné depuis l'introduction
du pattern. Ajout de StartAsync (fire-and-forget avec log d'erreur) et StopAsync
propre dans OnExit (timeout 5 s pour libérer les sockets sans TIME_WAIT bloquant).
Installer (PSLauncher.iss) : règles firewall TCP 47623 + UDP 47624 préposées
via netsh advfirewall, profile=private,domain (PAS public — safety net même
si l'utilisateur connecte le PC à un Wi-Fi public). Inutile si firewall OFF
(cas du déploiement actuel) mais pose les rails pour les déploiements futurs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deploy auto de l'API PHP de stats (parallèle Report/Doc) :
- ApiToolDeployer : copy _api/ → C:\xampp\htdocs\proserve\ avec atomic
rename + backups horodatés + revert + pruning. Strict clone du pattern
DocToolDeployer.
- ApiToolConfig dans LocalConfig (default folder "proserve" en minuscule
pour matcher l'install standard PROSERVE côté client).
- Settings → Avancés : section dédiée (HtdocsRoot, FolderName, AutoDeploy,
MaxBackups, Re-déployer, liste backups + revert) + ApiBackupViewModel.
- DI registration dans App.xaml.cs, injection MainViewModel, appel
post-install après le bloc Doc.
- 8 nouvelles strings i18n (5 langues).
Fix WebView2 vide en Program Files :
- Set WEBVIEW2_USER_DATA_FOLDER vers %LocalAppData%\PSLauncher\WebView2\
dans App.OnStartup. Sans ça, WebView2 essaie d'écrire son cache à côté
du .exe (read-only en Program Files) → init silencieusement KO →
onglets Report/Doc vides.
Refresh auto WebView2 sur navigation d'onglet :
- Hook IsVisibleChanged sur ReportWebView et DocsWebView. À chaque
ré-affichage, CoreWebView2.Reload() pour voir les données serveur à
jour. Le binding Source garde la nav initiale au cold start.
Fix UI : Cancel décalant le footer pendant Check updates :
- Nouveau flag IsDownloadActive distinct de IsBusy. Visibility du bouton
Cancel passe de IsBusy → IsDownloadActive : couvre uniquement les DL,
plus aucune apparition pendant Check updates / Install / Uninstall /
Self-update qui décalait tout le layout 1 s.
DL robustness (multi-PC) :
- SafeInstallAsync : error boundary sur l'install handler. Plus aucune
exception silencieuse — log + popup d'erreur visible (résout les cas
"le 2e PC ne démarre pas, sans message").
- DownloadManager : vérif explicite que CHAQUE segment a Completed=true
&& DownloadedBytes >= Length AVANT le SHA-256. Le check actualSize
était inutile (fichier sparse-préalloué).
- MaxRetryAttempts Polly 6 → 10 pour absorber les outages PHP-FPM
request_terminate_timeout sous charge multi-PC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Installeur :
- Bascule en Program Files (autopf) avec PrivilegesRequired=admin.
L'auto-update reste fonctionnel : LauncherSelfUpdater détecte le dossier
protégé et lance PS_Launcher.Updater.exe via Verb=runas (UAC à chaque MAJ).
- build-installer.ps1 : détection ISCC.exe robuste (Program Files + LocalAppData
pour install winget user-mode + PATH), messages d'erreur explicites avec URL
et commande winget, suppression accents (compat PS 5.1 sans BOM).
Téléchargements multi-PC :
- DownloadManager : détection des connexions fermées prématurément (PHP-FPM
request_terminate_timeout). Sans ça, segment marqué Completed=false sans
exception → fichier sparse final avec trous → SHA-256 KO. Lance maintenant
HttpResumableException(transient) pour que Polly retry au bon offset.
- ParallelDownloadSegments : 16 → 6 par défaut pour permettre plusieurs PCs
simultanés sans saturer le pool PHP-FPM OVH (~30 workers).
UI :
- Fenêtre maximisée par défaut au démarrage (WindowState=Maximized).
- Fix maximize qui cachait le footer / barre de DL (WM_GETMINMAXINFO clamp
sur work area, remplace le margin hack 7px imprécis).
- Sidebar : bloc info en bas avec PS_Launcher vX.Y.Z + IPv4 locale alignés.
- Copyright remonté plus près du footer (margin 28 → 8).
- Settings → Health checks : boutons ▲/▼ pour réordonner, CanExecute auto.
- HealthCheck refresh défaut : 5000 → 2000 ms.
Bug fix UI :
- MainViewModel.RebuildList preserve l'état du row actif pendant un DL.
Sinon ouvrir Settings/License pendant un DL recréait les rows from scratch
→ UI affichait "Reprendre" + "Annuler" alors que le DL tournait. Les
progress callbacks pointent maintenant sur _activeRow (résolution dynamique)
au lieu de capturer le row local.
Defaults config :
- ServerBaseUrl : example.com → asterionvr.com (out-of-the-box).
- Vive Business Streaming check : HtcConnectionUtility → rrserver.
Backoffice (server/admin/licenses.php) :
- Action set_max_machines : bouton "Slots" pour ajuster max_machines à chaud
sur une licence existante. Refus de descendre sous le nombre de machines
déjà actives.
Build :
- AllowUnsafeBlocks=true sur PSLauncher.App.csproj (compat WinRT generator
récent qui émet du code unsafe dans WinRTGenericInstantiation.g.cs).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug WPF classique avec WindowStyle=None + WindowChrome : quand la fenêtre
passe en Maximized, elle déborde physiquement de la taille de
ResizeBorderThickness sur chaque bord (~7 px ici). Conséquence : la Row 3
(footer) passe sous la barre des tâches Windows, et la barre de
téléchargement devient invisible alors que tout le reste continue de
fonctionner.
Fix : on applique un Margin=7 sur le Grid root quand WindowState=Maximized
via un DataTrigger sur le RelativeSource Window. Le trigger se déclenche
automatiquement à chaque maximize/restore — pas de code-behind nécessaire.
Test : maximize + minimize + restore plusieurs fois → le footer reste
toujours visible et la fenêtre revient à sa taille normale sans glitch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The Win32 MessageBox uses the system white-on-gray look that clashes
with the launcher's dark theme (jarring transition every time we
confirm a cancel, show an error, etc.). Replaced with a custom WPF
dialog that:
- Drops in : same Show(message, title, button, icon, default) API as
System.Windows.MessageBox, returns the same MessageBoxResult.
- Looks native to the launcher : dark Brush.Bg.Window background,
Brush.Border outline, Brush.Text.Primary content, AccentButton for
the default action and SecondaryButton for the others.
- Iconography by emoji + colour code : ⛔ red (Error), ⚠ amber (Warning),
❓ neutral (Question), ℹ blue (Information). Mapped from MessageBoxImage.
- Buttons localised via Strings.ActionOk / ActionYes / ActionNo /
ActionCancel — works in fr/en/zh/th/ar like the rest of the UI.
- Owner = currently active window (falls back to MainWindow then
CenterScreen) so it positions correctly even from a child dialog.
- Esc resolves to Cancel/No matching MessageBox semantics.
The 21 MessageBox.Show call sites across MainViewModel, SettingsViewModel,
LicenseDetailsDialog and MainWindow now use ThemedMessageBox.Show with
no signature change — full grep replace + tiny `using` adjustments.
Versions bumped to 0.16.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps to consolidate the dark-theme fix for inline `code` in the
release notes viewer (commit 6d11b43) into a versioned client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Simplifies the rename done in ce3979d : PS_Launcher.exe is the only
name. Removes the dual-path lookup in LauncherSelfUpdater, the
duplicated taskkill blocks in the .bat scripts, the legacy patterns
in .gitignore, and the explanatory comments about the migration.
Cleaner code, single source of truth for the binary name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns the executable name with the repo / product naming. Backward-
compatible: existing installs that have PSLauncher.exe on disk continue
to work — the self-updater overwrites the file at the running .exe path
without renaming, so the historical filename persists on those machines.
Changes
- AssemblyName : PSLauncher → PS_Launcher (both App and Updater csproj)
- Inno Setup : MyAppExeName, MyUpdaterExeName, OutputBaseFilename all
now use PS_Launcher prefix
- LauncherSelfUpdater : looks for PS_Launcher.Updater.exe first, falls
back to legacy PSLauncher.Updater.exe so old installs keep updating
- Build scripts (build-launcher / build-updater / build-installer /
build-all) : taskkill both legacy AND new names; output paths printed
with new names
- .gitignore : added PS_Launcher.exe / PS_Launcher.Updater.exe /
PS_Launcher-*.exe patterns alongside the legacy ones; also ignored
the WebView2 user-data folder and Office ~$ lock files
- Server admin/launcher.php : URL pattern now generates
PS_Launcher-{ver}.exe ; SignManifest's existing tolerant glob
*{ver}*.exe still matches both names
- Versions bumped to 0.14.0 (App + Updater + installer .iss)
Migration story for clients
- Brand-new install via PS_Launcher-Setup-0.14.0.exe → PS_Launcher.exe
on disk
- Existing install (PSLauncher.exe) auto-updates to v0.14 → file stays
named PSLauncher.exe but contains v0.14 code; self-updater fallback
ensures future updates keep working
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous deploy mechanism kept the old version under {target}.old
just long enough to swap, then deleted it — no way back if the new
report had a runtime bug only visible at use time.
Now each deploy keeps the previous active version under
{FolderName}.backup-yyyyMMdd-HHmmss/. The N most recent are kept
(MaxBackups, default 3) and older ones are pruned in best-effort.
UI in Settings → Avancés → Outil Report :
- "Conserver N backups" input (0 = no history, back to v0.12 behavior)
- List of backups with date + size + cryptic folder name + per-row "↶ Revert"
- Confirmation dialog: "Revert to {date}? The currently deployed
version will itself be saved as a new backup, so you can switch back."
Implementation
- IReportToolDeployer: + ListBackupsAsync, + RevertAsync, + BackupInfo,
+ DeployStatus.BackupNotFound.
- ReportToolDeployer: deploy renames {target} → backup-{ts}; PruneOldBackups
trims to MaxBackups; RevertAsync atomically swaps current ↔ chosen
backup, the previous current becoming itself a fresh backup.
- ReportBackupViewModel + DataTemplate for the list rows.
- Strings: backup labels, "↶ Revert", confirmation message.
Caveats documented in the design discussion:
- Backups cover ONLY the report tool files. DB migrations are not reverted
(irreversible). If a migration broke the schema, that's a separate fix.
- No HTTP-HEAD post-deploy auto-revert: too fragile for false positives.
Revert stays a deliberate manual action.
Versions bumped to 0.13.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each PROSERVE ZIP can ship a _report/ subfolder alongside _migrations/.
After install + DB migration, the launcher copies _report/ to the local
XAMPP htdocs (default C:\xampp\htdocs\ProserveReport\) so the Reports
tab in the sidebar always serves the matching version.
Service (PSLauncher.Core/ReportTool)
- IReportToolDeployer + ReportToolDeployer.
- Atomic deploy: copy to {target}.new/ → rename current to .old/ →
rename .new → final → cleanup .old. Apache never sees a half-state.
- Skip silencieux si _report/ absent du ZIP (PROSERVE release qui ne
touche pas au Report).
- DeployStatus enum (SkippedNoSourceFolder, XamppNotFound, Failed,
Deployed) renvoyé pour gestion UI claire.
Config (LocalConfig)
- ReportToolConfig (HtdocsRoot, FolderName, AutoDeploy). Defaults
matchent l'install standard ASTERION (C:\xampp\htdocs\ProserveReport).
Install pipeline (MainViewModel)
- Étape 6 après extraction + migrations. Progress reporté via le footer
Library : « 📂 Déploiement Report : 47/120 — assets/main.js ».
XAMPP introuvable → dialog avec lien vers Settings → Avancés.
Settings UI (SettingsDialog → Avancés)
- Nouveau bloc « OUTIL REPORT (XAMPP htdocs) » : 2 champs path + checkbox
AutoDeploy + bouton « 📂 Re-déployer maintenant » qui rejoue le deploy
sur la dernière version installée. Utile après changement de chemin
htdocs ou si l'auto-deploy avait foiré (XAMPP éteint).
Versions bumped to 0.12.0 (App + Updater + installer .iss).
Côté release : tu ajoutes _report/ dans le source de PROSERVE, tu zippes,
tu uploades. Les clients récupèrent ZIP + migrations + Report en une
seule install.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-visible feature change (sidebar appears on every screen) deserves
a minor bump rather than a patch. WebView2 dependency adds ~5 MB to the
single-file exe (the Edge runtime itself is system-installed, not
shipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps version across the three places it lives:
- PSLauncher.App.csproj : 0.9.0 → 0.10.0
- PSLauncher.Updater.csproj : 0.8.0 → 0.10.0 (catch-up; the updater
hadn't been bumped since 0.8 — version doesn't gate behavior, just
matches the .exe metadata)
- installer/PSLauncher.iss : 0.8.0 → 0.10.0 (installer artifact name
PSLauncher-Setup-0.10.0.exe)
This release adds bundled SQL migrations applied automatically after
each PROSERVE install, so a new build that needs schema changes ships
the .sql alongside its binaries and the launcher rolls them out without
manual intervention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coordinated changes so the auto-update flow works on every
existing install location.
LauncherSelfUpdater.cs
----------------------
- Probe the target directory with a write/delete of a temp file. If
it fails (e.g. install in Program Files without admin), set
UseShellExecute=true + Verb=runas on the Updater process so UAC
prompts the user once for elevation. If the directory is writable
(user-mode install or portable layout), skip elevation entirely.
- Switched from ProcessStartInfo.ArgumentList to a quoted Arguments
string because Verb=runas requires UseShellExecute=true, which
ignores ArgumentList. Paths get explicit quotes to survive spaces.
Updater Program.cs
------------------
After the file swap, the relaunch was a direct Process.Start(target).
With UAC elevation that propagates admin to the new launcher, then to
its child PROSERVE_UE_5_5.exe — undesirable. Replace with
`explorer.exe "<target>"`: explorer is always running in the user's
normal token, opens the exe via shell, the new process inherits the
non-elevated session. Standard de-elevation trick.
installer/PSLauncher.iss
------------------------
Switch from system-wide install (Program Files, requires admin) to
per-user (DefaultDirName={localappdata}\Programs\..., PrivilegesRequired=
lowest). Auto-update then never needs UAC at all on fresh installs.
Existing installs in Program Files keep working thanks to the runas
fallback above.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The value x64compatible was added in Inno Setup 6.3 — earlier 6.x
versions reject it with "Value of [Setup] section directive
ArchitecturesAllowed is invalid". Using plain x64 keeps compatibility
with the whole 6.x line. Modern users on 6.3+ are still happy since
x64 still resolves correctly there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three deliverables shipped together so the next deployment cycle has a
clean distribution story.
(1) Auto-update of the launcher itself
---------------------------------------
- Models: RemoteManifest gains an optional `launcher` section
(LauncherInfo: version, minRequired, download {url,size,sha256},
releaseNotesUrl). Server-side, sign-manifest.php passes it through
unchanged; admins edit versions.json with the new launcher entry +
upload PSLauncher-X.Y.Z.exe to /builds/launcher/.
- Core: ILauncherSelfUpdater compares assembly version against
manifest.launcher.version using the existing SemVer parser, and
reuses DownloadManager (Range/resume/sha256 already proven on the
game ZIPs) to download the new exe into
%LocalAppData%/PSLauncher/selfupdate/.
- New project PSLauncher.Updater (~34 MB self-contained console exe):
spawned by the main app with --target / --source / --pid / --launch.
Waits for the main process to exit (or for the file lock to release),
backs up the current exe to .bak, copies the new file in place, and
restarts. .bak survives the swap so the user can roll back manually.
- App.csproj now declares Version=0.5.0 — currently shipped baseline.
PSLauncher.App.csproj sets a fixed AssemblyVersion so reflection-based
comparison works deterministically.
- MainViewModel.PromptLauncherUpdate: dialog after CheckForUpdates if
the manifest advertises a newer launcher. Download with progress in
the existing footer, then Application.Shutdown() so the Updater can
do its job.
(2) Inno Setup installer
------------------------
installer/PSLauncher.iss + build-installer.ps1 produce a single
PSLauncher-Setup-X.Y.Z.exe (~80 MB) that installs into
Program Files\ASTERION VR\PSLauncher\, drops both PSLauncher.exe and
PSLauncher.Updater.exe side by side (the updater MUST live next to
the target), creates Start Menu + optional Desktop shortcuts, and
registers a clean uninstall entry. The user's %LocalAppData%
(license, logs, cache) is intentionally untouched on uninstall — same
license survives a reinstall.
build-installer.ps1 chains dotnet publish for both projects and ISCC
in one command. README explains the bump-version workflow.
(3) HMAC-signed download URLs
-----------------------------
- New PHP route GET /api/download-url/{version} (Authorization: Bearer
<licenseKey> or ?key=...). Validates the license, checks
download_entitlement_until >= minLicenseDate of the version, and
returns a HMAC-signed URL (path|exp|licId, hash_hmac SHA-256, valid
1 h) + sha256 + sizeBytes for verification.
- /builds/.htaccess routes every *.zip request to gate.php. gate.php
validates exp, lic, sig (constant-time hash_equals), then streams
the file with Range: support so the launcher's resume keeps working.
Audit log gets a download_url_issued entry per request.
- Client-side wired transparently: LicenseService gains
GetSignedDownloadUrlAsync(version) that GETs the endpoint with the
decrypted license key from DPAPI. MainViewModel calls it before
every download; if the endpoint returns 404/401/network-error, the
client falls back to the manifest's plain download.url (graceful
degradation for setups that haven't deployed gate.php yet).
Note on PHP streaming for 14 GB ZIPs: gate.php uses set_time_limit(0)
+ ignore_user_abort(true) + 1 MiB chunked fread with periodic flush.
Works on OVH mutualisé but holds a PHP-FPM slot for the duration. If
parallel downloads scale past a few clients, switch to
mod_xsendfile or migrate /builds/ to Cloudflare R2 with native
S3-presigned URLs and remove the gate entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>