Compare commits

..

122 Commits

Author SHA1 Message Date
d21c1f24b7 Release Template files 2026-07-13 11:37:58 +02:00
e673b1954c v1.0.13 — Section « Arguments de lancement » indépendante du mode auto
Feature request : jusqu'à v1.0.12, les CLI args passés à PROSERVE_UE_*.exe
n'étaient utilisés QUE dans _config.AutoMode.Args, et seulement pour la
version désignée AUTO. Un opérateur qui voulait des flags globaux appliqués
à TOUS les lancements manuels (ex : -nosplash, -log, -fps=90) devait soit
activer le mode auto sur chaque version manuellement, soit modifier le
raccourci Windows (perdu au prochain install).

Fix : nouvelle section « Arguments de lancement » dans Settings → Avancés,
au-dessus du bloc « Mode auto ». Ces args sont concaténés en PREMIER dans
la ligne de commande passée à Unreal ; les args du mode auto s'ajoutent
DERRIÈRE quand la version courante est celle marquée AUTO.

Sémantique de merge :
  • Non-auto (comportement neuf) : cliArgs = DefaultLaunchArgs
  • Auto (comportement étendu)    : cliArgs = DefaultLaunchArgs + AutoMode.Args
  • Doublons de clé : Unreal FParse prend la dernière occurrence — les
    auto args écrasent silencieusement un default homonyme. Voulu (ex :
    -fps=90 en default, -fps=120 en auto).

Impl :
  • Model : LocalConfig.DefaultLaunchArgs (List<AutoModeArg>, reuse du type
    existant). Empty par défaut → rétro-compat total.
  • MainViewModel.LaunchVersion : composition de argList (default puis auto).
  • SettingsViewModel : nouvelle ObservableCollection DefaultLaunchArgs +
    AddDefaultLaunchArgCommand + Save/Load persistence.
  • SettingsDialog.xaml : nouvelle carte au-dessus d'AutoMode, patron
    identique (Key + Value + Remove par ligne + bouton Ajouter).
  • Strings.cs : SettingsLaunchArgs + SettingsLaunchArgsHelp (FR/EN/CN/TH/
    AR/ES/DE).

Rétro-compat : configs existantes qui n'ont pas le field DefaultLaunchArgs
en JSON → deserialize en List vide → cliArgs = null sur launch manuel (=
comportement d'avant, identique).

Bump : 1.0.12 → 1.0.13 (nouvelle feature UI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-09 12:30:06 +02:00
f8af338faf v1.0.12 — Badge channel en violet pour se distinguer du BETA orange
Feedback UX post-1.0.11 : le badge channel bleu (#3B82F6) était trop
proche du bleu du statut « Available » — pas assez de contraste
sémantique pour distinguer instantanément « quel channel ? » de
« quel état ? ». Passage sur violet (#8B5CF6) qui n'entre en conflit
ni avec le BETA orange (#F59E0B) ni avec les brushes de statut.

Deux sites (compact row + FeaturedVersion), aucune autre modif.

Bump : 1.0.11 → 1.0.12 (patch cosmétique).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 17:20:12 +02:00
d845edb90f v1.0.11 — /manifest : opt-in multiChannel pour ne plus dédupliquer par version
Bug rapporté après v1.0.10 : client sur license channel=full avec les deux
entries v1.5.4.32 dans le backoffice (Entry #1 tagged firefighter+full,
Entry #2 tagged full), le launcher n'affichait toujours qu'une seule ligne.

Root cause côté serveur, PAS côté client cette fois. Manifest.php faisait
un « group by version + pick most specific » dans filterVersions() —
comportement historique introduit pour ne pas faire crasher les vieux
clients qui déduisaient par ToDictionary(v.Version). Résultat : Entry #2
était filtrée avant même d'atteindre le launcher.

Ce filtrage était nécessaire à l'époque (vieux clients) mais bloque tous
les fixes multi-channels client (v1.0.8-1.0.10) qui avaient rendu le
client capable d'afficher plusieurs entries au même numéro.

Fix : opt-in via query param sur /manifest?multiChannel=1

Server (Manifest.php) :
  • filterVersions() prend un `bool $multiChannel = false`.
  • Si true → skip group-by, retourne toutes les entries visibles.
  • Si false (défaut, vieux clients) → comportement historique préservé.
  • Query param `multiChannel` lu depuis $_GET, transmis à filterVersions.

Client (ManifestService.FetchFromOvhAsync) :
  • Ajoute `?multiChannel=1` inconditionnellement. Un serveur ancien
    ignore silencieusement le param (pas de header d'échec).
  • Combiné avec &channel=X quand la license a un channel.

Rétro-compat :
  • Vieux client (v1.0.9-) + serveur nouveau : n'envoie pas multiChannel=1,
    serveur dédupe comme avant, launcher ne crash pas.
  • Client nouveau (v1.0.11+) + serveur ancien : le param est ignoré,
    même comportement qu'avant (dédup côté serveur, une seule row visible).
  • Client nouveau + serveur nouveau (config voulue) : les deux entries
    remontent, le row-key-par-Id de v1.0.10 fait le reste.

Bump : 1.0.10 → 1.0.11 (fix ciblé serveur+client).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 17:16:58 +02:00
dc72e7eee6 v1.0.10 — Fix v1.0.8/9 : row identity par entry Id, pas par folder name
Bug rapporté : après update client vers 1.0.9, un opérateur ne voyait qu'UNE
ligne alors que le manifest en contenait deux au même numéro. Root cause :
mon refactor v1.0.8 keyait les rows par folder name résolu via
GetInstallFolderName(). Sur les manifests existants (où l'opérateur n'avait
pas encore migré vers un installFolderTemplate distinct par channel), les
deux entries résolvent au même dossier « PROSERVE v1.5.4.32 » — le dico
TryAdd droppait la seconde silencieusement (juste un log warn).

Fix : row identity passe sur l'Id de l'entrée manifest (unique par entry,
auto-généré server-side depuis longtemps via generate_entry_id()). Le folder
name reste utilisé pour le matching installed ↔ remote quand l'entryId
manque (installs d'avant v1.0.8 qui n'ont pas encore été re-installés).

Bénéfices :
  • Les deux entries s'affichent MÊME si elles partagent un
    installFolderTemplate. L'install guard côté client bloquera l'écrasement
    au moment de l'install avec le message clair habituel.
  • Migration transparente : les installs existants continuent d'être matchés
    par folder name tant qu'ils n'ont pas d'EntryId dans leur meta. Au
    ré-install, le meta reçoit son EntryId et le matching devient canonique.

Détails :

── Model ─────────────────────────────────────────────────────────────
• InstalledVersion : nouveau champ optionnel EntryId (default null pour la
  rétro-compat des call sites existants).

── Registry ──────────────────────────────────────────────────────────
• Scan() populate EntryId via TryReadEntryId(dir). null si le fichier meta
  n'existe pas ou si la clé est absente (install antérieur à v1.0.8).

── MainViewModel.RebuildList ─────────────────────────────────────────
• remoteByRowKey : keyé par Id de l'entry (fallback folder name si le
  manifest est très vieux et n'a pas d'id).
• installedByRowKey : keyé par EntryId lu du meta ; fallback = folder name
  ; fallback ultime = héritage de l'Id d'un remote match si un install
  legacy pointe vers un remote qui, lui, a un Id.
• Tri VersionOrder : lookup version + isBeta par rowKey via deux dicos
  rawByRowKey/installedByRowKey (au lieu de folder name).

── VersionRowViewModel ───────────────────────────────────────────────
• RowKey : priorité (Remote.Id → Installed.EntryId → folder name → Version).
  Aligné avec la logique RebuildList pour que les lookups par RowKey
  trouvent la row correcte.

Bump : 1.0.9 → 1.0.10 (fix critique du refactor v1.0.8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 17:11:13 +02:00
53c9e0519c v1.0.9 — Badge channel affiché uniquement en cas de conflit de version
Feedback UX post-1.0.8 : le badge channel bleu était toujours visible dès
qu'une entrée manifest avait un channel non-default. Sur les setups
mono-channel (99 % des cas, y compris les clients firefighter/police qui
ne voient QUE leur channel + les entries default), ça polluait chaque row
avec un badge sans valeur informative — le badge ne sert qu'à distinguer
deux rows partageant un numéro de version.

Fix : nouveau flag HasVersionConflict sur VersionRowViewModel, positionné
par RebuildList après construction (group by Version, count >= 2 → true).
HasChannelBadge devient (ChannelBadge non vide AND HasVersionConflict) —
badge visible seulement quand une AUTRE row visible partage le même numéro.

Résultat :
  • Client firefighter voit v1.5.4.32 (default) → pas de badge (row unique)
  • Client firefighter voit v1.5.4.32 (default) + v1.5.4.32 (firefighter)
    → les DEUX rows affichent leur badge (« default » n'a pas de badge par
    convention, mais firefighter a le sien → distingue visuellement)
  • Client dev/opérateur voit tous les channels → badge apparaît sur toute
    row en conflit

Notification via [NotifyPropertyChangedFor(HasChannelBadge)] sur la
property auto-générée HasVersionConflict — les bindings XAML se rafraîchissent
correctement quand le flag change entre deux RebuildList (ex : upload
d'une entrée concurrente côté serveur puis refresh manifest client).

Bump : 1.0.8 → 1.0.9 (patch UX).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 17:02:26 +02:00
28c5ca5877 v1.0.8 — Multi-channels : afficher plusieurs entries au même numéro + install guard anti-collision
Contexte : après le fix v1.0.5 (/download-url disambigué par filename), un
opérateur peut avoir un manifest avec deux entries partageant un numéro de
version sur des channels différents (ex : proserve-firefighter-1.5.4.32 vs
proserve-full-1.5.4.32). Deux problèmes restants :

1. Le client n'affichait qu'UNE row : `remote.ToDictionary(v.Version)` dans
   RebuildList crashait sur duplicate key.
2. À l'install, les deux entries résolvaient au même dossier via le default
   `installFolderTemplate = "PROSERVE v{version}"` → l'install le plus récent
   écrasait silencieusement le précédent (ZipInstaller rename en .bak-{ts}
   puis delete en background).

Solution end-to-end :

── Client ─────────────────────────────────────────────────────────────
• RebuildList refactor : index par folder name (résolu via GetInstallFolder-
  Name()) au lieu de par version. Deux entries au même numéro deviennent
  visibles dès qu'elles ont des templates distincts. Warning log si deux
  entries résolvent au même folder.
• VersionRowViewModel : nouveau RowKey (basename du folder ou fallback
  Version), ChannelBadge (premier channel non-default). Sites de lookup
  (DL-in-flight preservation, 404 retry) migrés sur RowKey.
• MainWindow.xaml : badge bleu channel affiché à côté du badge BÊTA, dans
  la row compact ET dans FeaturedVersion.
• Install guard : refuse une install si le dossier cible contient déjà un
  .proserve-meta.json avec un entryId différent. Le meta stocke maintenant
  l'entryId à chaque WriteInstallMetadataAsync. Message clair localisé
  (FR/EN/CN/TH/AR/ES/DE) qui pointe l'opérateur vers le backoffice.
• VersionManifest client model : nouveau champ optionnel `Id` (mappé sur
  le champ serveur existant), utilisé pour identifier l'entrée source.
• Registry regex broadened : accepte `PROSERVE(-<channel>)? v...` en plus
  du `PROSERVE v...` legacy. Les folders custom par channel sont scannés.

── Serveur admin (versions.php) ──────────────────────────────────────
• Nouveau champ éditable `install_folder_template` dans le formulaire
  d'ajout ET dans edit_meta. Validation regex (contient {version}, charset
  whitelisted).
• Default intelligent à la création : si un seul channel non-default est
  coché, pré-remplit avec "PROSERVE-<channel> v{version}". Sinon garde
  "PROSERVE v{version}" (legacy).
• Validation croisée : refuse la save si deux entries résolvent au même
  dossier, avec un message clair qui suggère un template alternatif.

── Rétro-compat ──────────────────────────────────────────────────────
• Vieux installs (sans entryId dans meta) : install guard fail-open, se
  laisse écraser à la ré-install et retrofit l'entryId.
• Vieux manifests (sans `id` sur les entries) : `Id` est null côté client,
  l'install guard reste passif, comportement identique à v1.0.7.
• Vieux serveurs (sans `install_folder_template` éditable) : le manifest
  reste avec le default généré par generate_entry_id, aucune breaking
  change. Le badge channel s'affiche quand même si `channels` est renseigné.
• Setups mono-channel (99 % des cas) : aucun changement visible, sort et
  matching identiques.

Bump : 1.0.7 → 1.0.8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 16:54:44 +02:00
f9820620dd v1.0.7 — UpdateChecker filtre isBeta selon canSeeBetas de la license
Fix latent identifié en marge du v1.0.6 : UpdateChecker.CheckAsync
retournait toujours la plus haute version disponible sans filtrer par
isBeta, indépendamment des droits de la license courante. Conséquence
possible : un client sans droits beta pouvait recevoir un popup
« Nouvelle version disponible : 1.5.4.32 » puis ne pas voir cette
version dans la liste (RebuildList applique le filtre canSeeBetas), et
au clic il n'aurait pas pu la télécharger (entitlement backend). UX
incohérente + confusion opérationnelle.

Fix : IUpdateChecker.CheckAsync prend maintenant un paramètre
`bool canSeeBetas`. UpdateChecker filtre les entrées isBeta=true de la
sélection LatestRemote quand ce flag est false. Le manifest complet
reste retourné dans le UpdateCheckResult pour le rendu ultérieur —
seule la variable "quelle version est proposée comme MAJ ?" est
concernée.

Côté caller (MainViewModel.CheckForUpdatesAsync), on passe
`_license?.CanSeeBetas ?? false` — même défaut que RebuildList,
comportement cohérent bout-en-bout.

Rétrocompat : aucun autre caller de CheckAsync dans le codebase (grep
vérifié). Le nouveau param est requis mais n'a qu'un site d'appel.

Bump : 1.0.6 → 1.0.7 (petit fix isolé, mérite d'être tracé séparément
puisque la sémantique change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 15:44:13 +02:00
c20d4603a2 v1.0.6 — Version finale non-beta > dernière beta au même préfixe 3-digit
Bug rapporté : après avoir itéré des builds beta 1.5.4.30 / .31 / .32
(isBeta=true, 4-digit), publier 1.5.4 (isBeta=false, 3-digit) comme
release finale ne rendait PAS 1.5.4 la version "courante" côté launcher.
Deux cas d'échec :

  1. Client canSeeBetas=true (opérateur/testeur) : les deux visibles,
     1.5.4.32 restait en tête du tri (SemVer strict : 1.5.4 == 1.5.4.0
     < 1.5.4.32).
  2. Client canSeeBetas=false avec 1.5.4.32 déjà installé : l'install
     locale n'est jamais filtrée par isBeta, donc restait en tête aussi.

Root cause : SemVer.CompareTo() traite le 4ᵉ digit comme un patch
post-release (documenté ainsi dans SemVer.cs pour supporter les itérations
de test 1.5.4.13 alignées sur leur release stable 1.5.4). Cette sémantique
casse quand le 4-digit est en fait un "pré-release" beta destiné à être
supplanté par la 3-digit finale.

Fix : nouveau helper VersionOrder.Compare(versionA, isBetaA, versionB,
isBetaB) qui ajoute une règle par-dessus SemVer :

  Au MÊME préfixe 3-digit (X.Y.Z égaux) ET statut beta différent, la
  version isBeta=false l'emporte, indépendamment du 4ᵉ digit.

Hors ce cas exact (préfixes 3-digit différents, ou même statut beta des
deux côtés) : SemVer strict, aucune régression sur les scénarios existants
(1.5.4.30 beta < 1.5.4.32 beta reste vrai, 1.5.4 < 1.5.5 reste vrai, etc.).

Application aux deux hotspots :

  • UpdateChecker.CheckAsync — tri par VersionOrder au lieu de SemVer,
    ET pour la comparaison isNewer, retrouve l'isBeta d'origine de l'install
    locale via lookup dans manifest.Versions (si l'entrée existe encore).
  • MainViewModel.RebuildList — tri combiné installé/remote via VersionOrder.
    L'isBeta est lookupé dans le RAW remote (avant le filtre canSeeBetas),
    sinon un client sans droits beta ayant installé une beta perdrait
    l'info et retomberait sur SemVer strict.

Migration : aucune côté data. Les versions publiées comme beta restent
identifiées par leur flag isBeta ; le launcher les considère automatiquement
comme pré-release dès qu'une non-beta au même préfixe 3-digit apparaît
dans le manifest. Publish 1.5.4 (isBeta=false) → devient la version featured
même sur les postes ayant 1.5.4.32 installé.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 15:38:20 +02:00
d52e29151e v1.0.5 — Fix /download-url sur manifests multi-channels (same-version)
Bug rapporté : sur un manifest avec deux entrées partageant le numéro de
version 1.5.4.32 (channels proserve-firefighter vs proserve-full), le
client se prenait au démarrage de l'install le garde-fou :

  « Incohérence serveur : l'endpoint /download-url retourne un nom de
    fichier différent du manifest. Manifest : proserve-full-1.5.4.32.zip.
    Signé : proserve-firefighter-1.5.4.32.zip. → DownloadUrl.php côté
    serveur doit lire le filename depuis manifest.download.url, pas via
    un template hardcodé. »

Root cause : DownloadUrl.php faisait un `foreach ... if version match {
break; }` — il retournait donc TOUJOURS la première entrée matchant le
numéro, quel que soit le channel réellement cliqué côté client. Pareil
que le bug de sync_one (même famille de problèmes), mais côté endpoint
runtime du client.

Fix côté serveur : /download-url accepte maintenant un query param optionnel
`?filename=proserve-full-1.5.4.32.zip`. Si présent, le foreach filtre sur
(numéro version AND basename(download.url) == filename attendu). Whitelist
défensive sur le filename (path traversal). Rétro-compat : sans param, le
1er match par numéro gagne comme avant.

Fix côté client : le client extrait le filename attendu de `row.Remote.
Download.Url` (déjà connu, signé Ed25519) et le passe à l'endpoint. Deux
sites d'appel modifiés : le call initial dans InstallVersionAsync + le
callback RefreshUrlAsync (utilisé quand un segment reçoit 403/410 mid-DL
et qu'il faut re-signer). Sans le refresh à jour, un DL long sur ADSL
tomberait au 1er refresh forcé.

Extension d'interface : ILicenseService.GetSignedDownloadUrlAsync prend
maintenant un `string? expectedFilename` en 2e param. Callers qui passent
null continuent de fonctionner comme avant (utile pour les tests).

Bump : 1.0.4 → 1.0.5 (bug fix ciblé sur les setups multi-channel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 13:49:22 +02:00
b0ca082b52 sync_one : hash uniquement l'entrée cliquée, pas toutes celles du même numéro
Bug : sur un manifest avec deux entrées partageant le même numéro de version
(channels multiples — ex proserve-firefighter-1.5.4.32.zip ET proserve-full-
1.5.4.32.zip, deux builds distincts pour firefighter vs full), cliquer le
bouton « 🔁 Hash » d'une ligne déclenchait le hash des DEUX ZIPs dans la
même requête HTTP. Résultat 2 × 14 Go = 28 Go dans une seule requête, ce
qui dépasse le timeout front d'OVH mutualisé (hard-limit invisible côté
PHP même avec set_time_limit(0)) → 500 Internal Server Error.

C'est pour ça que « des hashs de la même taille passaient sans problème
avant » : c'est le cas 28 Go des DEUX ZIPs en une requête qui est nouveau
(depuis l'introduction des channels), pas la taille du single 14 Go qui
passait déjà.

Fix : nouveau paramètre $onlyEntryId sur SignManifest::run(). Prend le pas
sur $onlyVersion. L'admin action sync_one passe l'entryId (unique par
ligne, généré par generate_entry_id()) au lieu du numéro de version. Une
ligne cliquée = un ZIP hashé, point. Le bouton « 🔁 Sync (all versions) »
(action sync_versions) continue de tout hasher — c'est ce que l'opérateur
demande explicitement.

Rétro-compat : si $onlyEntryId est null (CLI, cron, anciens callers),
$onlyVersion filtre comme avant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 13:42:39 +02:00
aae339287b SignManifest : capture les erreurs dans un log accessible via SFTP
Sur OVH mutualisé les logs Apache (error_log Apache) ne sont accessibles
que via le manager web — pas grep-able en SFTP, donc difficile de
diagnostiquer un 500 quand ça se reproduit.

Fix : redirige log_errors vers manifest/.signmanifest-error.log dès l'entrée
de run(). Trois canaux capturés :

1. ini_set error_log → tout ce que PHP loggue habituellement va aussi
   dans notre fichier (warnings inclus).
2. register_shutdown_function → attrape les E_ERROR / E_PARSE / fatal
   errors qui court-circuitent l'exécution avant tout catch normal.
3. try/catch autour de getOrComputeSha256() par version, avec un START/DONE
   log de chaque étape. Si le process meurt, on saura pile lequel des ZIPs
   du manifest était en cours au moment du crash.

L'opérateur peut maintenant SFTP le fichier après un 500 et coller le
contenu ici pour identifier la cause réelle (l'hypothèse timeout du commit
précédent était probablement fausse — l'opérateur a rapporté que plein de
hashs de la même taille avaient marché avant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 13:40:02 +02:00
46d7c461c5 Fix SignManifest 500 sur ZIPs de 14 Go via OVH mutualisé
Bug rapporté depuis le backoffice : cliquer « 🔁 Hash » sur une nouvelle
version renvoyait un 500 Internal Server Error générique (« Please contact
the server administrator at postmaster@asterionvr.com »). L'opérateur ne
pouvait donc plus signer une release après upload SFTP du ZIP.

Root cause : hash_file('sha256', $zip) sur un fichier de 14 Go via le SAN
mutualisé OVH prend 5-10 min. Le max_execution_time PHP par défaut (30-60s)
tue le process bien avant. Apache remonte alors 500 avec son boilerplate
par défaut, sans log utile pour l'opérateur.

Fix en défense en profondeur :

1. set_time_limit(0) + ignore_user_abort(true) au début de run(). Couvre
   TOUS les callers (admin web, cron, CLI). ignore_user_abort évite qu'un
   refresh de l'onglet backoffice interrompe un hash en cours (10 min = ~un
   café — l'opérateur peut être tenté de refresh).

2. Passage de hash_file() → hash_init + hash_update_stream en boucle
   16 Mo par chunk. Deux bénéfices :
   • flush() entre chaque chunk = heartbeat pour le proxy Apache/OVH front,
     évite un timeout côté serveur web même si PHP a le droit de continuer.
   • set_time_limit(300) glissant à chaque chunk = si un chunk prend +5 min
     c'est vraiment un disque HS, pas juste un gros fichier — on n'est pas
     bloqué sur un unique timer géant.

Mémoire : hash_update_stream() ne buffere pas, streaming pur, aucun risque
d'OOM même sur 14 Go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 13:32:01 +02:00
ff05edbe7e v1.0.4 — Conserver les sauvegardes : user-data wins over ZIP defaults
Bug rapporté par l'opérateur : upgrade 1.5.4.30 → 1.5.4.32 sur un poste,
les paramètres customisés dans 1.5.4.30 n'étaient pas visibles au premier
lancement de 1.5.4.32 alors que la case « Conserver les sauvegardes » était
cochée.

Root cause : le ZIP de la nouvelle version bundle des .sav par défaut (typ.
PROSERVE_UE_5_7/Saved/SaveGames/GeneralSettings.sav = réglages usine).
Après extraction, ce fichier existe déjà dans le dossier cible. Ma logique
« non-destructive » précédente skippait alors la copie depuis la version
précédente pour « ne pas écraser un fichier bundlé » — mais c'est
exactement l'inverse qu'on veut : les données utilisateur (progression,
réglages persos, replays) DOIVENT primer sur les defaults du ZIP.

Fix : mode « USER-DATA WINS » — on écrase toujours le fichier cible s'il
existe. La version précédente contient soit la même valeur (no-op), soit
la valeur customisée par l'opérateur (recherchée). Aucune raison légitime
de préserver les defaults bundlés au détriment de user data.

Logging bumpé Debug → Information sur les branches critiques de
CopyPreviousSaveGamesAsync (nombre de versions scannées, path source
résolu, count trouvé par sous-dossier). Facilite le diagnostic de ce genre
de régression à l'avenir sans obliger l'opérateur à activer un mode verbose.

Bump : 1.0.3 → 1.0.4 (bug fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 14:55:10 +02:00
8d8cf32e12 Bump WebView2 SDK 1.0.3792.45 → 1.0.3800.47 (clear NU1603 warning)
L'ancien build sortait :
  NU1603: PS_Launcher dépend de Microsoft.Web.WebView2 (>= 1.0.3792.45)
  mais Microsoft.Web.WebView2 1.0.3792.45 est introuvable.
  Microsoft.Web.WebView2 1.0.3800.47 a été résolu à la place.

Microsoft a purgé 1.0.3792.45 du feed NuGet entre temps (révoqué pour
sécurité ou simplement nettoyé). NuGet remontait à la version supérieure
qui était de toute façon le minimum acceptable.

Pas de changement de comportement — la SDK reste un wrapper du même
WebView2 Runtime (qu'on installe via le bootstrapper Microsoft).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 10:35:31 +02:00
e2d9171df1 v1.0.3 — Render fallback software pour environnements sans accel GPU
Bug rapporté : sur un poste client fraîchement installé, la fenêtre du
launcher reste TOUT BLANCHE. Aucune exception loguée, services en
arrière-plan fonctionnent (manifest fetché, LAN discovery active,
hosting alive), juste rien à l'écran. WebView2 confirmé installé, donc
ce n'était pas la cause.

Diagnostique : c'est un mode de panne classique de WPF sur certains
environnements où l'accélération matérielle DirectX échoue silencieusement.
La fenêtre est créée, la message loop tourne, mais rien ne se rend
visuellement. Aucune exception ne remonte parce que le pipeline graphique
échoue en dehors du try/catch managé.

Environnements à risque :
  - Sessions Bureau à distance (RDP) — accélération matérielle limitée
  - Machines virtuelles sans hardware accel (Hyper-V Gen 1, VirtualBox,
    VMware basic)
  - GPU Intel HD anciens avec drivers obsolètes
  - Windows Server sans Desktop Experience
  - PCs corporate avec GPU émulé (RDS, Citrix)

Fix : check au démarrage et fallback vers RenderMode.SoftwareOnly dans
3 cas :
  1. RenderCapability.Tier == 0 — WPF lui-même dit "pas d'accel possible"
  2. GetSystemMetrics(SM_REMOTESESSION) != 0 — session RDP détectée
  3. Variable d'env PSLAUNCHER_SOFTWARE_RENDER=1 — override manuel

Le mode choisi est logué au démarrage (Tier=X RDP=Y EnvOverride=Z →
SoftwareOnly=W), donc en cas de re-rapport on saura immédiatement si
c'est ce code qui s'est déclenché.

Trade-off : rendu software ~30% plus lent sur le scroll et les
animations, mais visible vs INVISIBLE. Le bon trade-off.

Pour l'utilisateur déjà bloqué AVANT cette release : set la var
PSLAUNCHER_SOFTWARE_RENDER=1 dans les variables d'environnement Windows
de la session user (Paramètres → Système → À propos → Paramètres
système avancés → Variables d'environnement), puis relancer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 10:32:31 +02:00
5b489dcfc5 Installer v1.0.3 : bundle WebView2 Runtime Bootstrapper
Bug rapporté : sur un poste client fraîchement installé sans Microsoft
Edge WebView2 Runtime (typique sur Windows Server, Win10 sans MAJ
récente, ou install corporate stripped down), la fenêtre du launcher
restait toute blanche au démarrage. Les contrôles WebView2 (utilisés
pour les onglets Reports et Documentation) tentent de s'initialiser
au render de MainWindow, échouent en silence faute de runtime, et la
fenêtre WPF reste vide visuellement.

Fix : on bundle le bootstrapper WebView2 (MicrosoftEdgeWebview2Setup.exe,
1.6 MB, redistribuable Microsoft) dans le setup. À l'install, un check
registre (3 emplacements : HKLM x64, HKLM x86, HKCU) détecte si le
runtime est déjà présent. S'il manque, on lance le bootstrapper en
silencieux qui télécharge ~150 MB depuis Microsoft et installe.

Si le téléchargement échoue (poste offline, firewall corporate qui
bloque go.microsoft.com), on ne fail PAS le setup global — le launcher
sera installé sans WebView2, le user verra le bug visuel et pourra
installer manuellement le runtime depuis Microsoft. Trade-off vs forcer
l'install qui pourrait bloquer indéfiniment des admins en offline.

Téléchargé depuis : https://go.microsoft.com/fwlink/p/?LinkId=2124703

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 08:35:39 +02:00
a6ea9b11af v1.0.2 — Conserver les sauvegardes et replays entre versions
Nouvelle option dans le popup « Mise à jour disponible » (case cochée par
défaut, décochable) : « Conserver les sauvegardes et replays de la version
précédente ». Après extraction du ZIP de la nouvelle version, le launcher
copie :
  • PROSERVE_UE_*/Saved/SaveGames/*.sav   (profils + progression Unreal)
  • PROSERVE_UE_*/Saved/Demos/*.replay    (replays de session pour debrief)

Source = version installée au plus haut SemVer autre que celle qu'on
vient d'installer (couvre upgrade ET ré-install). Le dossier projet UE est
déduit du nom de l'exe (PROSERVE_UE_5_7.exe → PROSERVE_UE_5_7/) — supporte
le passage UE_5_5 → UE_5_7 (renommage transparent : on copie du dossier
source vers le dossier cible, peu importe leur numéro UE).

Sémantique non-destructive : si la nouvelle install contient déjà un
fichier au même chemin (profil/replay bundlé dans le ZIP), il n'est PAS
écrasé. La version la plus à jour côté installer prime pour ce slot
précis ; les autres fichiers créés par l'opérateur en cours d'utilisation
sont copiés normalement. Liste des sous-dossiers + globs en table statique
(PreservedSavedSubdirs) pour faciliter l'ajout futur (Logs/Config user…).

Best-effort : exceptions IO loggées en warn mais l'install n'échoue pas
pour une copie qui foire (handle verrouillé, accès refusé). Si l'option
est décochée OU si aucune version précédente n'est installée, no-op
silencieux.

Côté UI : checkbox au-dessus des boutons Plus tard / Télécharger, avec
tooltip détaillant les deux chemins. État remonté via dialog.PreserveSaveGames
et lu par MainViewModel APRÈS l'écriture du .proserve-meta.json (donc
avant les redists & SteamVR merge). Pour les resumes de DL interrompus,
le défaut est TRUE (l'utilisateur a déjà confirmé la première fois).

i18n complète : FR/EN/CN/TH/AR/ES/DE pour le libellé de la case, le
tooltip et le StatusMessage « Copie des sauvegardes et replays depuis
vX.Y.Z (N fichiers)… ».

Bump : 1.0.1 → 1.0.2 (feature patch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 18:03:57 +02:00
b3844bfe16 no message 2026-06-22 15:33:20 +02:00
43a6070a65 v1.0.1 — Fix SteamVR merge : scope aux blocs system.generated.* uniquement
Bug : à chaque install, la popup « Vérification de la santé système / X
blocs vont être mis à jour » apparaissait alors que la source `_steamvr/
steamvr.vrsettings` n'avait pas changé entre les sous-versions. L'opérateur
voyait le launcher toucher au fichier SteamVR de manière répétitive et
inutile.

Root cause : le diff comparait TOUS les blocs racine du source vs target.
Or le fichier source contient (légitimement, pour la doc opérateur) des
blocs user-/machine-spécifiques copiés depuis une machine de référence :
  • DesktopUI       (position fenêtres SteamVR — varie par poste)
  • GpuSpeed        (calibration GPU — RTX 3080 chez l'opérateur vs autre
                     GPU chez le client)
  • LastKnown       (HMD info — Focus3 ou autre)
  • dashboard, steamvr (settings UI — installID utilisateur, etc.)
  • trackers        (mapping device — déjà configuré côté client)
Ces blocs DIFFÈRENT toujours entre la machine de référence (où le source
a été capturé) et chaque poste client → faux positif de diff systématique.

Fix : on restreint la diff + le push aux blocs racine `system.generated.*`
(typiquement system.generated.openxr.proserve_ue_5_5.proserve_ue_5_5.exe,
etc.) qui contiennent les bindings tracker workshop URLs — la VRAIE config
que le launcher est censé pousser. Tout le reste du fichier source est
maintenant ignoré.

Sémantique précise pour un bloc system.generated.* :
  • ABSENT côté target → push complet (les 4 leaf keys CurrentURL,
    PreviousURL, AutosaveURL, NeedToUpdateAutosave)
  • PRÉSENT côté target → check sur les seules leaf keys *_CurrentURL_openxr
    et *_PreviousURL_openxr. Si elles matchent → skip silencieux. Si elles
    diffèrent (= opérateur a updaté la binding workshop) → réécriture des
    2 leafs ciblées, les autres (AutosaveURL, NeedToUpdate) sont laissées
    intactes (gérées par SteamVR).

Cleanup : helpers `DeepMergeInto` et `WouldDeepMergeChange` retirés (plus
référencés). Nouveau helpers ciblés `OpenXrBindingUrlsDiffer` (check) et
`ReplaceOpenXrBindingUrls` (write). Doc XML mise à jour côté interface.

Bump : 1.0.0 → 1.0.1 (patch fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 14:13:18 +02:00
73d084a703 v1.0.0 🎉 — Production release. i18n Espagnol + Allemand (launcher + emails).
Première release MAJEURE après ~30 itérations 0.x. Le système est complet
et stable pour la production : distribution PROSERVE multi-channels, install
launcher cross-fleet, monitoring santé VR, sécurité (signatures Ed25519,
HMAC URLs présignées, settings lock per-license, RFC1918 filter cache LAN).

i18n nouvelles langues (Espagnol + Allemand) :
 - Strings.cs : signature T() étendue avec 2 params optionnels (es?/de?)
   en plus des 5 obligatoires. Fallback automatique sur l'anglais quand
   non traduit → infrastructure prête immédiatement, traduction progressive
   sur les ~317 strings du launcher selon les besoins terrain.
 - ~30 strings critiques traduites maintenant (top bar, body, status badges,
   actions principales Launch/Install/Cancel/Yes/No, MessageBox titles).
 - Available[] inclut es/Español + de/Deutsch ; auto-détection Windows
   pour es-* / de-* funcionne au premier launch.
 - Init() whitelist mise à jour.

Emails release announce localisés par license :
 - Migration 005 : ajout `language VARCHAR(8) NULL` sur licenses (whitelist
   fr/en/es/de/zh/th/ar, NULL = fallback English).
 - getEmailStrings() PHP étendu : dictionnaire 7 langues × 14 strings.
   Traduction complète es + de (gérable : 28 nouvelles strings, vs 634
   pour traduire le launcher en intégralité).
 - notify_release boucle par license : récupère language + locale fallback
   'en' + render avec la bonne locale → chaque destinataire reçoit son
   email dans la langue de SA license, indépendamment des autres clients.
 - Subject localisé aussi (« PROSERVE v1.5.4 ya está disponible » /
   « PROSERVE v1.5.4 ist jetzt verfügbar »).
 - Release notes body reste TOUJOURS en anglais (évite d'avoir à maintenir
   N traductions du Markdown des notes).
 - Direction RTL préservée pour l'arabe (dir="rtl" sur <html> + <table>).

Admin UI : dropdown langue dans la modal Contacts d'une license + whitelist
backend (set_contact_emails accepte fr/en/es/de/zh/th/ar).

Bumps : 0.29.10 → 1.0.0 — milestone V1, premier release majeur en prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:01:50 +02:00
500f7d12e6 v0.29.10 — Admin UI refonte (modals + onglets), emails de release, 4-digit versions
Admin backoffice — UI redesign :
 - Pages versions.php + licenses.php : remplace les <details> inline qui
   débordaient horizontalement par UN bouton « ✎ Modifier » par row qui
   ouvre une modal <dialog> avec onglets. 5 tabs versions (Méta, Notes,
   BÊTA, Channels, Avancé) + 6 tabs licenses (Prolonger, Slots, Channel,
   BÊTA, Lock, Contacts, Machines). Délégation JS unique pour les onglets.
 - Bouton 📋 Copier la clé license dans chaque row (Clipboard API + feedback
   visuel ✓ vert 1.5s). Évite le détour par phpMyAdmin pour transmettre la
   clé aux clients.
 - Overlay « hashing en cours » plein écran sur tous les boutons de hash
   (3-5 min sur OVH pour 13 Go ZIP). Spinner CSS + message contextualisé
   par scope (bulk vs single).
 - Date de release passée de datetime-local à date (l'heure n'a pas de
   sens UX), avec défaut = aujourd'hui pour release_at et aujourd'hui-1an
   pour min_license_date (= license standard couvre les releases sur 1 an).

Emails de notification release :
 - Migration 004 : colonne contact_emails TEXT NULL sur licenses (CSV)
 - Onglet « Contacts » sur la modal licenses pour saisir les emails par
   license (parsing tolérant : CSV, ligne par ligne, point-virgule)
 - Bouton « ✉ Notifier » par version : POST notify_release filtre les
   licenses éligibles (channel match + min_license_date + can_see_betas
   pour BÊTA) et envoie un email HTML à chaque contact (dédup global)
 - Template email table-based + bgcolor (compat Outlook/Word engine),
   navy foncé #0F172A, logo Asterion en CID embed (= affichage direct
   sans demande de permission Outlook), bouton download installer
   centré (align="center" + margin auto), release notes en <pre>
 - Mailer.php helper : parse emails, multipart/related avec attachments
   inline, fallback execCommand pour clipboard

4-digit version support (X.Y.Z.B) :
 - SemVer Parse/CompareTo/ToString gèrent 3 ou 4 digits ; Build absent =
   0 implicite (1.5.4 == 1.5.4.0 < 1.5.4.13). HasExplicitBuild préserve
   le format d'origine au round-trip.
 - Regex InstallationRegistry étendue avec (?:\.\d+)? → reconnaît
   « PROSERVE v1.5.4.13 » côte-à-côte avec « PROSERVE v1.5.4 » sur disque
 - Server-side : versions.php, launcher.php, DownloadUrl.php, api/index.php,
   Releasenotes.php — toutes les regex de validation acceptent le 4ᵉ digit
 - Use case : dev/test iterations cohabitant avec leur release stable

Bugs fixes :
 - migrate.php : strip ligne par ligne les commentaires SQL avant le
   check is-empty. Sans ça, le PREMIER chunk d'un fichier migration
   (= header + premier ALTER) commençait par `--` et était silencieusement
   skip → ALTER jamais appliqué. Affectait migrations 003, 004.
 - SignManifest::getOrComputeSha256 ignorait son cache interne quand force
   demandé par le caller, retournant l'ancien hash en 0 ms même après
   re-upload SFTP (avec mtime préservé). Propage maintenant le flag $force.
   Bouton « 🔁 Hash » per-row force maintenant un re-calcul systématique.
 - DownloadManager : 416 (Range Not Satisfiable) ajouté aux URL-refresh
   triggers, avec HEAD probe pour comparer taille serveur vs manifest →
   message d'erreur explicite si ZIP tronqué. Bps display lissé sur une
   fenêtre glissante de 12 samples (3 s) → plus de clignotement quand un
   segment finit / Polly retry. SHA mismatch popup enrichi avec les
   deux SHAs (attendu vs calculé) extraits via regex de l'exception.
 - DownloadUrl.php : signature de l'URL utilisait un template hardcodé
   /builds/proserve-{version}.zip, ignorant tout rename serveur. Lit
   maintenant download.url du manifest et signe le filename réel.

Strings i18n (5 langues) :
 - ~15 nouveaux : SHA mismatch enrichi avec sources, 416 size mismatch,
   stale manifest, manifest refreshed auto-retry, force fresh menu

Bumps : 0.29.7 → 0.29.10 (4-digit support + accumulated UI fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:24:50 +02:00
50da755a92 v0.29.7 — SteamVR merge + résilience DL (404 retry, force fresh, SHA purge)
SteamVR + Vive Business Streaming settings merge :
 - Nouveau ISteamVrSettingsDeployer : si _steamvr/steamvr.vrsettings est
   dans le ZIP, deep-merge récursif des blocs racine dans le fichier user
   (clé absente → ajout, deux objets → recurse, sinon → replace ; les
   sous-clés cible non touchées sont préservées — crucial pour "trackers")
 - Auto-localisation via registre Steam (HKCU/HKLM) + fallback Program Files
 - Phase pré-check (CheckMergeNeededAsync) : skip silencieux si la cible
   est déjà à jour (deep equality) → pas de kill SteamVR/VBS inutile, pas
   de popup à l'opérateur. Si changements nécessaires → popup OK/Annuler
   avec nombre de blocs qui changeront + liste des process à fermer.
 - Liste de kill construite depuis les health checks de type "Process"
   (l'opérateur connaît déjà sa stack VR) + guardians (Vive Business
   Streaming en tête car il relance SteamVR auto)
 - Settings UI : section dédiée avec opt-in, override path, liste process
   éditable + affichage du path canonique attendu

Résilience téléchargements :
 - 404 mid-DL traité comme 403/410 (URL refresh trigger) au niveau segment :
   on extrait le nouveau filename via /download-url server-side, retry transparent
 - Auto-retry install une fois après refresh manifest sur 404 : l'opérateur
   ne voit rien si la cause était un manifest local stale
 - Si retry échoue aussi en 404 → message ciblé "manifest serveur stale"
   (problème côté serveur, pas client)
 - Sur SHA-256 mismatch : auto-purge du cache LAN local (.zip + .sha256)
   + message d'erreur dédié avec source du DL (peer ou OVH) + nouveau menu
   "↻ Forcer re-téléchargement" pour purger état + cache manuellement
 - Détection client-side de l'incohérence "signed URL filename != manifest
   URL filename" avant DL : abort immédiat plutôt que 14 Go pour rien
 - IZipCacheStore.InvalidateAsync : nouvelle API pour purger un cache par
   version (utilisée par SHA mismatch handler + Force Fresh menu)

Bug serveur (DownloadUrl.php) :
 - L'endpoint construisait l'URL signée avec un template hardcodé
   `/builds/proserve-{version}.zip`, ignorant complètement
   download.url du manifest. Conséquence : un opérateur qui rename
   son ZIP pour buster le cache CDN OVH (ex. proserve-full-1.5.4.zip)
   voyait toutes ses releases retournent du 404 silencieux côté client.
 - Fix : on lit basename(parse_url(entry.download.url).path), whitelist
   sur le filename, vérif is_file() avant de signer, erreur 500 explicite
   si manifest et filesystem désynchros.

Strings (5 langues) :
 - ~20 nouvelles : SteamVR popups + status, SHA mismatch dialog + source
   labels, force fresh confirm + menu, manifest stale + auto-retry status

Bumps : 0.29.6 (déployé hors-commit pendant la session) → 0.29.7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:52:00 +02:00
01793eb32d v0.28.12 — Fix DL >100% : onBytes au checkpoint, pas par WriteAsync
Régression introduite par v0.28.11 (durability checkpoint) : le footer
affichait des pourcentages > 100% en fin de DL, même sans pause manuelle.

Cause : seg.DownloadedBytes était devenu durable (incrémenté au checkpoint
tous les 64 MiB), MAIS onBytes(seg.Index, n) continuait à fire par
WriteAsync. Quand Polly retry un segment mid-stream (fréquent sur OVH
mutualisé qui coupe les requêtes longues via PHP-FPM
request_terminate_timeout) :

  Attempt 1 :
    - onBytes fire pour bytes 0..30 MiB (live)
    - HttpResumableException (PHP-FPM kill)
    - Dispose flush, mais seg.DownloadedBytes encore à 0 (dernier checkpoint)
  Polly retry attempt 2 :
    - segStart = seg.Start + 0
    - Re-download bytes 0..30 MiB (overwrite same content sur disque, OK)
    - onBytes fire À NOUVEAU pour ces 30 MiB ← DOUBLE COMPTAGE

Multiplié par 16 segments × N retries → aggregate dépasse total.

Fix : onBytes fire UNIQUEMENT au checkpoint, avec la valeur inFlight
juste avant la reset. Comme ça les bytes d'une attempt qui a failé ne
sont jamais reportés (la failure se produit AVANT que le checkpoint
soit atteint), et la retry re-télécharge + reporte une seule fois.

Trade-off : UI updates tous les 64 MiB par segment au lieu de chaque
4 MiB. Avec 16 segments en parallèle, ça fait ~10-20 reports/s en pic,
le reporter task échantillonne à 4 Hz de toute façon, invisible côté UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:06:45 +02:00
eae0514058 v0.28.11 — Multi-segment DL : durability checkpoint pour fix SHA fail post-pause
Bug rapporté : ~80% des reprises après pause failaient la vérification
SHA-256 finale alors que la DL semblait s'être passée correctement et
que le fichier était de la bonne taille. Root cause : seg.DownloadedBytes
était incrémenté juste après le retour de WriteAsync, mais cet appel met
seulement les bytes dans le buffer interne du FileStream (4 MiB) — pas
forcément sur disque ni dans le cache OS.

À la pause, le ct est cancelé, le segment throw une OperationCanceledException
au prochain await, et `await using var dst` dispose le FileStream — qui
SENSE flush le buffer vers le disque mais avec useAsync=true + cancellation
en cours, ce flush peut être partiel ou échouer silencieusement. Résultat :
le compteur dit "X bytes écrits" mais le disque en a moins. Au resume,
segStart = seg.Start + X évite donc les bytes perdus, ces régions
restent en zéros sparse (pré-alloc), et le SHA final fail.

Fix : pattern checkpoint. Le compteur seg.DownloadedBytes n'est mis à
jour qu'APRÈS un FlushAsync(CancellationToken.None) réussi. Le flush
est forcé non-cancelable pour éviter qu'une pause l'interrompe en
plein milieu. Granularité : tous les 64 MiB de download par segment.

Invariant garanti après le fix :
  seg.DownloadedBytes <= bytes_réellement_sur_disque

Au pire, en cas de pause, seg.DownloadedBytes est UN PEU en arrière de
ce qui est sur disque (jusqu'à 64 MiB par segment). Au resume, on
re-télécharge ces bytes — ils sont écrasés avec le même contenu, le
SHA final passe. Le cas dangereux ("compteur en avance, trou de zéros
sur disque") est définitivement impossible.

Bonus : initialisation de aggregateBytes depuis sum(seg.DownloadedBytes)
au lieu de state.DownloadedBytes pour éviter un drift cosmétique du
footer après resume (state.DownloadedBytes capturait la live aggregate
incluant l'in-flight, donc pouvait dépasser le total durable).

Trade-off : un peu plus de re-download au resume (jusqu'à 1 GB pour
16 segments × 64 MiB) en échange d'une fiabilité totale. Le user ne
verra pas la différence sur un 14 GB de DL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:25:51 +02:00
efb53f079e v0.28.10 — Mode auto + settings lock par license + fix post-install state
Mode auto (auto-launch d'une version désignée au démarrage et après exit) :
 - Master toggle + version désignée via bouton AUTO (orange) sur chaque row
 - Countdown 5 s configurable avec popup d'annulation (StartAutoLaunchCountdown)
 - Args CLI passés à PROSERVE : opérateur tape juste le nom (autoconnect, login),
   le launcher ajoute -/=/quotes. Format produit identique à un .bat.
   Fix : passage de ArgumentList à Arguments string pour éviter le quotage auto
   .NET sur les args contenant '=' (cassait FParse::Value).
 - Garde-fou anti double-lancement : refuse de lancer si _runningProserve vivant
   OU IProcessLauncher.IsRunning() détecte une instance externe. Silent pour
   les triggers auto, popup pour les clics manuels.
 - Option « Attendre santé système » : countdown différé tant que les health
   checks ne sont pas tous OK (phase 1 du dialog avec liste des pending).

Settings lock par license (remplace l'ancien lock global manifest) :
 - Colonne settings_lock_password_hash sur table licenses (migration 003)
 - Backoffice : bouton 🔒 par license pour set/clear hash SHA-256
 - Payload /license/validate inclut settingsLockPasswordHash (v3 canonique)
 - 3-tier fallback signature : v3 → v2 → legacy pour compat cache offline
 - SettingsLockService : in-memory unlock state, gate l'expander Avancés
 - SettingsLockDialog : prompt mdp, persiste déverrouillé jusqu'au restart

Fix post-install : la row restait en visuel « Installing » jusqu'au prochain
Check Updates. Reset explicite row.State=InstalledIdle + _activeRow=null
AVANT le RebuildList post-install pour purger l'instance orpheline.

Migrations :
 - 002_channel_betas.sql réécrit en ALTER simples (DELIMITER cassait
   migrate.php qui split sur ';\n')
 - migrate.php tolère « Duplicate column/key name » comme idempotent

Strings (5 langues, ~25 nouvelles) :
 - Renomme « Relance automatique » → « Lancement automatique » (dialog
   utilisé pour les 3 entry points : clic, startup, post-exit)
 - Tooltips auto-mode, settings lock prompts, health wait phase, etc.

Bumps : 0.27.4 → 0.28.10 (csproj + .iss).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:56:55 +02:00
9b17dfa84c v0.27.4 — Exe per-version configurable + auto-install redists Unreal
== Exe par-version, configurable via le backoffice ==

Pour éviter de devoir bumper le launcher à chaque changement d'Unreal Engine
(PROSERVE_UE_5_5.exe → PROSERVE_UE_5_7.exe), l'exe à lancer est maintenant
déclaré par version dans le manifest serveur, configurable depuis le form
admin.

- server/admin/versions.php : nouveau champ "Nom de l'exécutable" dans le
  form d'ajout de version (regex strict anti-path-traversal). Par défaut
  PROSERVE_UE_5_7.exe.
- InstallationRegistry : nouveau .proserve-meta.json écrit dans chaque dossier
  d'install fraîchement extrait (contient l'exe declaré par le manifest).
  Scan() lit cette meta pour résoudre l'exe sans avoir besoin du manifest
  en mémoire (offline / pré-check).
- Fallback glob PROSERVE_UE_*.exe pour les vieux installs sans metadata —
  garantit la backward compat de l'UE 5.5 actuel sans intervention.

== Auto-install des redists Unreal depuis _redist/ ==

Quand une version change d'Unreal (typique : UE 5.5 → 5.7), les redists
Microsoft VC + UEPrereqSetup doivent être installés sur le PC. Au lieu de
demander à l'opérateur de les installer à la main, le launcher détecte
maintenant un dossier _redist/ dans la racine du ZIP de release et lance
automatiquement tous les .exe dedans.

- MainViewModel.InstallRedistsAsync : après le ZipInstaller, scan _redist/
  pour .exe, lance chacun avec /install /quiet /norestart + Verb=runas
  (UAC popup par installer, inévitable car les redists écrivent dans
  Program Files). Tri alphabétique (préfixe 01_, 02_, … pour forcer un
  ordre si besoin).
- InstallationRegistry.MarkRedistInstalledAsync : trace redistInstalledAt
  dans .proserve-meta.json après succès. Reinstall de la même version :
  skip silencieux, pas de UAC popup chain.
- Best-effort sur les exit codes : les "already installed" retournent
  souvent un code non-zero, on log mais on continue (l'install PROSERVE
  ne doit pas être bloquée par un redist mineur).

Côté ZIP de release : crée _redist/ à la racine avec les .exe à installer
(typiquement VC_redist.x64.exe + UEPrereqSetup_x64.exe).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:11:57 +02:00
aab2e41152 Admin licenses : bouton BÊTA visible directement, plus dans un menu replié
Avant : pour basculer l'accès BÊTA d'un client, il fallait deviner qu'il
fallait cliquer sur le code <channel> dans la cellule pour ouvrir un
<details> caché — pas de chevron, pas de label « modifier ». L'admin
trouvait pas l'option et pensait qu'elle n'existait pas.

Après : sur la page Licenses, chaque ligne affiche directement deux
boutons cliquables :
- « Activer BÊTA » / « ✓ BÊTA actif » (bouton secondary / warning selon
  l'état) — toggle en un clic sans menu
- « ✎ Channel » qui déplie le dropdown pour changer le channel

L'état actuel reste visible au-dessus (code du channel + badge β BÊTA
si actif). Beaucoup plus discoverable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:55:22 +02:00
d9c3f09e9a v0.27.2 — Persiste channel + canSeeBetas dans le cache license
Bug : le LicenseValidationResponse réhydraté par GetCached() ne contenait
plus les champs Channel et CanSeeBetas (introduits en v0.26.0). Cause :
LocalConfig.LicenseConfig ne persistait pas ces deux champs, donc à chaque
relance du launcher (ou simple GetCached), Channel retombait à null et
CanSeeBetas à false.

Conséquence visible : un client avec license channel='firefighter'
côté serveur fetchait toujours /api/manifest (sans ?channel=), recevait
le manifest default-only, et ne voyait jamais le build firefighter même
après re-validation de license.

Fix :
- Ajout des champs CachedChannel + CachedCanSeeBetas sur LicenseConfig
  (persistés dans %LocalAppData%\PSLauncher\config.json)
- SaveCached() écrit ces champs depuis la réponse signée du serveur
- GetCached() les restaure dans la LicenseValidationResponse renvoyée

Migration silencieuse : un launcher pre-v0.27.2 a CachedChannel=null
et CachedCanSeeBetas=false dans son config.json. Au prochain Vérifier
les MAJ, RefreshLicenseFromServerAsync re-valide la license, le serveur
renvoie les valeurs courantes, SaveCached les persiste. La fois d'après
le launcher fetch correctement avec ?channel=X.

À tester chez l'utilisateur :
1. Recompiler + réinstaller le launcher en v0.27.2
2. Settings → Vérifier les MAJ (force la revalidation license)
3. Vérifier %LocalAppData%\PSLauncher\config.json :
   "CachedChannel": "firefighter" doit apparaître
4. Le manifest-cache.json devrait alors contenir le bon ZIP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:37:19 +02:00
67678fe173 Manifest : dédup par version, l'entry channel-spécifique gagne sur default
Cas reporté : un client firefighter recevait DEUX entries v1.5.3 (default
+ firefighter) parce que la sémantique additive est correcte pour la
visibilité, mais ne dédupe pas. Le launcher faisait alors un
ToDictionary(v => v.Version) qui collisionne sur "1.5.3" et garde
silencieusement le premier (la default), donc le mauvais ZIP s'affichait
dans la UI.

Fix : Manifest.php groupe par version après le filtre, et pour chaque
groupe à plusieurs entries pick l'entry la plus spécifique au client :
1. priorité à celle taggée avec le channel propre du client (firefighter)
2. sinon l'entry default sert de fallback

Sémantique pour un user firefighter :
- v1.5.3 (default) + v1.5.3 (firefighter) → renvoie uniquement firefighter
- v1.4.0 (default uniquement) → renvoie default (visible)
- v1.5.3 (firefighter uniquement) → renvoie firefighter

Fix purement server-side, pas de modif client requise. Le launcher
recevra naturellement un seul ZIP par version, le bon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:30:34 +02:00
38194c6f0c Fix : require Crypto.php pour la route manifest + accepter id v8hex pour releasenotes
Deux bugs introduits par v0.27.x dans api/index.php :

1. La route /api/manifest ne require pas lib/Crypto.php → erreur 500
   « Class PSLauncher\Crypto not found » dès qu'on tente la re-signature
   du manifest filtré (depuis v0.27.0). Conséquence : le client voit
   tout cassé, pas seulement les versions filtrées.

2. La regex de route /api/releasenotes/X.Y.Z n'accepte que les versions
   au format SemVer. Avec v0.27.1, les release notes sont addressables
   par id stable (v + 8 hex chars), donc le manifest pointe vers
   /api/releasenotes/va3f2c891 qui matchait pas la regex → 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:12:55 +02:00
72f99e0c56 v0.27.1 — Versions : permettre numéro identique sur channels différents
Cas d'usage rapporté : « je peux avoir une version 1.5.3 pour les
pompiers et une version 1.5.3 pour la police ». Le check anti-doublon
sur le numéro de version bloquait la création — c'était un faux check
puisque la duplication SUR DES CHANNELS DIFFÉRENTS est légitime.

Changements :

1. ID stable interne : chaque entry de version reçoit un `id` immutable
   généré à la création (`v` + 8 hex aléatoires). Ce id devient l'identifiant
   primaire dans les forms et URLs admin. Le numéro de version reste un
   libellé human-readable, possiblement dupliqué.

2. Migration douce : loadManifest() ajoute un id aux entrées qui n'en
   ont pas, et auto-save pour persister. Stable aux reloads suivants.
   Les manifests legacy fonctionnent sans aucune action manuelle.

3. Check anti-doublon déplacé sur le NOM DE ZIP au lieu du numéro de
   version. Deux entries v1.5.3 sont autorisées si elles pointent vers
   des ZIPs différents (proserve-1.5.3-police.zip + proserve-1.5.3-pompier.zip).
   Une vraie ambiguïté sur disque (deux entries → même ZIP) est rejetée.

4. Toutes les actions row-level (edit_meta, edit_notes, set_beta,
   set_channels, toggle_available, delete, set_skip_hash, sync_one)
   identifient l'entry par `id` au lieu de `version`. Forms HTML mis
   à jour en conséquence.

5. Release notes : addressables par id stable. Stockées en
   releasenotes/{id}.md. Compat ascendante : si un fichier {id}.md
   n'existe pas, l'admin lit le legacy {version}.md (pour les notes
   créées avant v0.27.1). L'endpoint API releasenotes/{key} accepte
   les deux formats.

6. Suppression d'une entry : nettoie le fichier {id}.md correspondant.

7. SignManifest::run filtre toujours par version string : si plusieurs
   entries partagent un numéro de version, toutes sont re-hashées via
   « 🔁 Hash » sur une row. Comportement correct (chaque entry a sa
   propre URL/ZIP, donc son hash spécifique), juste moins efficient.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 09:02:00 +02:00
49a3b855af v0.27.0 — Channels comme TAGS sur versions (refactor du modèle)
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>
2026-05-05 08:51:10 +02:00
0c69bb1c85 Fix : badge 'absent' faux positif sur ZIPs en sous-dossier de channel
Le commit 7ff5e56 avait changé $zips pour stocker des chemins relatifs
(asterion-vr/proserve-1.5.3.zip) afin de lister les ZIPs en sous-dossier,
mais le check par-row utilisait toujours basename(URL) → comparait
"proserve-1.5.3.zip" contre ["asterion-vr/proserve-1.5.3.zip"], donc
in_array() retournait toujours false → badge "absent" alors que le ZIP
était bien là.

Fix : on compute zipRel depuis l'URL de la même façon que $zips (extrait
tout après /builds/), pour que les deux côtés du in_array() utilisent le
même format. Le tooltip "attendu :" affiche aussi le chemin relatif
complet, c'est plus clair pour le SFTP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:37:35 +02:00
7638a7a25c Admin versions : nom du ZIP libre + renommage post-création
À l'ajout d'une version, nouveau champ "Nom du fichier ZIP (optionnel)" :
par défaut proserve-{version}.zip mais l'admin peut surcharger pour
distinguer des builds homonymes entre channels :
  builds/asterion-vr/proserve-1.4.6-asterion.zip
  builds/client-foo/proserve-1.4.6-foo.zip

Sur les versions existantes, la dialog "Méta" gagne un champ "Renommer
le ZIP" qui :
- ne touche que le filename, garde le préfixe builds/{channel}/
- invalide le sha256 (REPLACE_AFTER_BUILD) + sizeBytes pour forcer le
  recalcul au prochain Sync — le ZIP physique change, le manifest
  refléterait sinon l'ancien hash et le client ferait fail la vérif.

Normalisation côté serveur via ps_normalize_zip_filename() :
- basename() pour empêcher tout path traversal (../)
- whitelist [a-zA-Z0-9_.-] (laisse les majuscules, points, tirets)
- auto-suffix .zip si manquant
- fallback sur proserve-{version}.zip si l'input devient vide après nettoyage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:19:24 +02:00
7ff5e56102 Fix : SignManifest et listing ZIPs supportent les sous-dossiers de channel
v0.26.0 générait des URLs builds/{channel}/proserve-X.Y.Z.zip dans le
manifest pour les channels non-default, mais SignManifest faisait juste
basename(url) → cherchait builds/proserve-X.Y.Z.zip à plat → ZIP introuvable
au moment du Sync.

Fixes :

1. SignManifest::resolveZipPath() : extrait tout le chemin après /builds/
   dans l'URL et le mappe sur {$buildsDir}/. Strip ../ pour anti-traversal
   même si le manifest est de toute façon signé Ed25519.

2. SignManifest fallback fuzzy étendu à 1 niveau de sous-dossier (glob */*.zip)
   pour le cas "admin a renommé le ZIP".

3. admin/versions.php $zips inclut maintenant builds/ + builds/*/ et stocke
   les chemins relatifs (asterion-vr/proserve-X.Y.Z.zip) au lieu du basename.
   Le check "référencé ?" matche aussi sur le chemin relatif.

4. Workflow text en haut de la page indique le bon chemin SFTP selon le
   channel actif (avec rappel "crée le sous-dossier si absent").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:14:02 +02:00
95c70903e7 Fix : 'Aller' silencieux sur création de channel + dropdown vide
Deux bugs UX rapportés sur v0.26.0 admin/versions.php :

1. Cliquer 'Aller' avec un nom contenant majuscules / espaces / accents
   ne faisait RIEN. Cause : pattern HTML5 strict (`[a-z0-9_-]{1,64}`) qui
   bloque la submission du form GET sans message visible. Selon le browser
   le tooltip de validation est barely visible, donc le user a l'impression
   que le bouton est cassé.

   Fix : retire le pattern strict, normalise côté serveur :
   « ASTERION VR » → « asterion-vr », « éàç!? » → « » (et on dit pourquoi).
   Flash success qui annonce la normalisation, ou flash error si le nom
   est inexploitable après nettoyage.

2. Après création d'un channel, le dropdown affichait encore '(default)'
   parce que listExistingChannels() filtre sur les versions-*.json
   existants et l'admin n'a pas encore ajouté de version donc le fichier
   n'existe pas. Du coup l'admin pensait que la création n'avait pas
   marché alors que la session était bien sur le nouveau channel.

   Fix : injection forcée du channel actif dans le tableau des options du
   dropdown avec label « X (vide — pas encore de versions) ».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:09:59 +02:00
2b95472393 Fix : 500 sur admin/versions.php (PHP imbriqué)
Mon edit v0.26.0 avait laissé un <?php à l'intérieur d'un bloc PHP déjà
ouvert. Erreur de syntaxe → 500. Refactor pour fermer le bloc PHP
proprement après Layout::header() avant de basculer sur le HTML.

Vérifié avec php -l (XAMPP) : « No syntax errors detected ».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 08:06:12 +02:00
9499c6ae58 Migration 002 : version portable MariaDB pré-10.0.2 (OVH mutualisé)
ALTER TABLE ... IF NOT EXISTS n'est dispo qu'à partir de MariaDB 10.0.2.
OVH mutualisé tourne sur du plus ancien → erreur 1064 syntaxe.

Remplacement par une PROCEDURE temporaire qui consulte INFORMATION_SCHEMA
avant chaque ALTER. Marche sur toutes versions MariaDB / MySQL 5.5+ et
reste idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 07:55:58 +02:00
c371a79f93 v0.26.0 — Channels per-license + tag BÊTA sur versions
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>
2026-05-05 07:38:00 +02:00
3d691c3e38 v0.25.10 — LAN P2P offline-friendly : manifest fallback + perf
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>
2026-05-04 17:51:50 +02:00
65ff8541b7 v0.25.4 — Cache LAN P2P : un PC sert les ZIPs aux autres du LAN
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>
2026-05-04 16:00:10 +02:00
7de0c01b97 v0.24.9 — Deploy _api/, fix WebView2 Program Files, DL robustness
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>
2026-05-04 13:53:13 +02:00
9de8e95910 v0.24.5 — Install Program Files, multi-PC DL reliability, polish UI
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>
2026-05-04 12:04:36 +02:00
be7ccfced5 v0.23.6 — Fix : footer (barre de DL) invisible en plein écran
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>
2026-05-03 21:06:54 +02:00
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
c50abeb8d0 infos pass 2026-05-03 10:58:51 +02:00
fd1e9e9e6b gitignore : catch-all WebView2 cache and renamed launcher exes
- *.exe.WebView2/ : couvre les variantes de noms (PSLauncher.exe.WebView2
  legacy + PS_Launcher.exe.WebView2 actuel + tout build de test futur)
- /PS_Launcher*.exe : un seul glob pour PS_Launcher.exe, PS_Launcher.Updater.exe,
  PS_Launcher-X.Y.Z.exe, PS_Launcher_X.Y.Z.exe (séparateur tiret OU underscore)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:51:50 +02:00
886245cc61 v0.16.0 — ThemedMessageBox replacing native MessageBox everywhere
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>
2026-05-03 10:46:53 +02:00
42dd0701f5 Footer : drop the leftover Margin=200 hack
The 200px left margin was a workaround when the footer was at outer
Row=2 with no column constraint and had to dodge the sidebar visually.
Now that Row=2 + Col=1 cleanly cantons the footer in the content
column (commit ab40b62), the margin pushes everything 200px further
right and creates a black gap at the bottom-left of the content area.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:43:01 +02:00
ab40b62430 Layout : sidebar spans full window height, footer scoped to content column
Restructured the outer Grid to a proper 2×3 layout instead of stacked
rows-with-margins :

  Outer Grid       Col 0 (200)   |   Col 1 (*)
  Row 0 (Auto)   ───── Top bar (ColSpan 2) ─────
  Row 1 (*)        Sidebar      |  Content area
                   (RowSpan 2)  |  (Library / Report / Doc)
  Row 2 (Auto)                  |  Footer (only on Library)

Previously the sidebar lived inside an inner body Grid limited to Row 1,
and the footer was at outer Row 2 with a hand-crafted 200px left margin
to fake the sidebar carve-out — which left a visible footer-background
strip across the bottom and broke the visual continuity of the sidebar.

Now :
- Sidebar Grid.Row=1 + RowSpan=2 → reaches the bottom of the window
  regardless of footer visibility
- Footer Grid.Row=2 + Grid.Column=1 → cleanly cantonné dans la colonne
  content, ne déborde plus
- Top bar Grid.Row=0 + Grid.ColumnSpan=2 → toujours full width
- Background image limitée à Row=1 + Col=1 → ne déborde ni sous la
  sidebar ni sous le footer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:38:07 +02:00
24d1841844 v0.15.0 — release notes Markdown : readable inline code
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>
2026-05-03 10:33:23 +02:00
6d11b43d17 Markdown : restyle inline code for the dark theme
Markdig.Wpf's default inline-code style applies a light Background +
"fancy" Foreground that becomes a near-illegible mustard-on-charcoal
mess in our dark theme.

Detect inline code by its FontFamily (Consolas / Courier / Typewriter,
applied by Markdig's default style) and rebrand:
  Background = #1A1F2A  (dark, blends with card)
  Foreground = #CDD6E4  (soft off-white, easy contrast)
  FontFamily = Cascadia Code, Consolas, monospace

Affects both the UpdateAvailableDialog and the ReleaseNotesViewerDialog
since they share BuildThemedDocument.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:25:37 +02:00
89c78c9b37 1.5.3 release notes: switch to English
The project is multi-language with English support across the launcher
UI; release notes follow suit. Same content, English wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:22:41 +02:00
27fa793351 PROSERVE v1.5.3 release notes
Markdown rendered by the launcher (Markdig) in the "Mise à jour
disponible" dialog. Sections : Nouveautés, Améliorations, Corrections,
Migration, Notes — same template as 1.4.6.

Covers : MM CQB scenario, weapon persistence per session, stat icon
refresh, VR-controller-only quick calibration, instructor UI fix.

Also documents the 3 SQL migrations the launcher will run automatically
+ the report tool backup/revert mechanism, so support can point users
to the right Settings panel if anything goes sideways post-install.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:19:46 +02:00
d09deaddb7 build-proserve-zip.bat : zippe une release PROSERVE prête à uploader
Usage : build-proserve-zip.bat 1.5.3
  → lit version\proserve-1.5.3\
  → produit version\proserve-1.5.3.zip (binaires UE5 + _migrations + _report)
  → affiche taille + SHA-256

Auto-détecte 7-Zip s'il est installé (multi-thread, 3-5x plus rapide sur
14 Go). Fallback sur PowerShell Compress-Archive (mono-thread) sinon.

Sans argument : liste les versions disponibles dans version\.
Avec checks : warn si _migrations/ ou _report/ ou PROSERVE_UE_5_5.exe
manquent (continue après 5s).

Sortie pédagogique à la fin : prochaines étapes (SFTP upload + actions
backoffice à effectuer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:12:58 +02:00
c706d09e72 Drop legacy PSLauncher.exe backward compat (not in prod yet)
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>
2026-05-03 10:08:23 +02:00
ce3979d131 v0.14.0 — rename PSLauncher.exe → PS_Launcher.exe (with backward compat)
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>
2026-05-03 09:55:41 +02:00
64fbda0191 Footer : align under content area, not under the left sidebar
The footer was at outer Grid.Row=2 with no column constraint, so it
spanned the full window width — visually overflowing under the 200px
left sidebar. Added a 200px left margin so the footer starts where
the content area starts. Comment makes the magic-number coupling
explicit so it stays in sync if the sidebar width changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:43:02 +02:00
1acb735641 PROSERVE v1.5.3 migrations : idempotent SQLs (FK syntax fix)
Schema source-of-truth for the proserveapi DB, versioned outside the
release ZIPs (the actual binaries live in version/ but are gitignored).

0001_Init.sql — full bootstrap of proserveapi (12 tables + view +
reference data + FKs). Reworked from a phpMyAdmin dump to be safely
re-runnable on:
  - fresh install (everything created)
  - existing customer DB pre-populated manually before launcher rollout
  - re-import via phpMyAdmin or mysql CLI
Idempotency tools used:
  - CREATE TABLE IF NOT EXISTS
  - INSERT IGNORE INTO ...  for reference rows
  - ADD PRIMARY KEY / KEY / UNIQUE KEY IF NOT EXISTS
  - ADD FOREIGN KEY IF NOT EXISTS `name` (...)  REFERENCES ...
    (note: MariaDB does NOT accept `ADD CONSTRAINT IF NOT EXISTS name
     FOREIGN KEY ...` — the IF NOT EXISTS goes after FOREIGN KEY, not
     after CONSTRAINT)
  - CREATE OR REPLACE VIEW
  - Removed the dump's `START TRANSACTION` / `COMMIT` (the launcher
    wraps each script in its own tx; embedded BEGIN/COMMIT would break
    the rollback semantics)
  - Removed `DEFINER=root@localhost` (uses CURRENT_USER, more portable)

0002_SessionType_ps_1.4.5.sql — adds id=7 'LongRange' + renames id=6
to 'Rescue'. Now uses INSERT IGNORE for the new row (skips if 0001
already inserted it on a fresh install) and the UPDATE is naturally
idempotent.

0003_UserSize_ps_1.5.0.sql — adds users.size column. Now uses
ADD COLUMN IF NOT EXISTS (the column is also in the recent 0001
baseline, so on a fresh install this will skip; on an upgrade from a
pre-0003 DB it actually adds it).

Validated locally on MariaDB 10.4 (XAMPP) with two consecutive runs of
each file: exit code 0 on every run, no errors.

.gitignore tightened so version/ only tracks _migrations/*.sql — the
~14 GB UE5 binaries and the _report/ build artifacts stay out of git.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:35:14 +02:00
562b4e5ed0 v0.13.0 — Report tool : timestamped backups + manual revert
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>
2026-05-03 09:15:09 +02:00
67f422539d v0.12.0 — Report tool auto-deploy from PROSERVE ZIP
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>
2026-05-03 09:05:40 +02:00
a2e67d5405 Footer is Library-only: hide on Report/Documentation tabs
The download/install progress footer made no visual sense on the
WebView pages — it would float above the embedded site. It now only
appears when CurrentPage = Library. The DL itself keeps running in
the background while the user browses Reports or Docs; switching
back to Library restores the live footer.

Implementation: FooterVisibility now ANDs with IsLibrary, and
CurrentPage notifies FooterVisibility/FooterText so the footer
collapses/reveals on tab switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:00:26 +02:00
7a09f73c44 Sidebar: move from right to left
Left-sidebar layout matches the convention of Steam, Discord, Spotify,
VSCode, etc. — nav anchored to the leading edge. Also feels less
intrusive: in a Library-centric default view the eye lands on the hero
card first, sidebar fades into the chrome on the left.

Just swaps the two ColumnDefinitions, the Grid.Column attributes and
the Border's BorderThickness (left edge → right edge of the sidebar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:56:07 +02:00
6d251e9eac v0.11.0 — right sidebar nav with Reports / Documentation tabs
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>
2026-05-03 08:53:14 +02:00
288cad585a Right sidebar nav: Library / Reports / Documentation tabs (WebView2)
The body of the launcher is now a 2-column layout:
- Left : swappable content area
- Right (200px) : sidebar with 3 nav buttons + active-state highlighting

Three pages, all under the same window chrome / footer:

1. **Library** (default) — the existing main view (featured version,
   other versions list, floating "Vérifier les MAJ" button, copyright).
2. **Reports** — embedded WebView2 navigated to a configurable URL.
   Default http://localhost/ProserveReport/ which matches the local
   stats tool installed on each customer machine alongside XAMPP.
3. **Documentation** — WebView2 with a configurable URL, falls back
   to a placeholder explaining where to set it if empty.

Implementation
- LocalConfig gains ReportUrl + DocumentationUrl (string).
- MainViewModel gains LauncherPage enum + CurrentPage state with
  Navigate{Library,Report,Documentation}Command. ReportUri /
  DocumentationUri parse the strings to Uri for WebView2.Source.
- Theme.xaml: NavButton style (transparent, left-aligned, hover
  darken, active state highlighted via Tag bool + accent left-border).
- InverseBoolToVisibilityConverter added (true → Collapsed) for the
  documentation placeholder fallback.
- Microsoft.Web.WebView2 NuGet (1.0.2792.45). Runtime is pre-installed
  on Win11 and auto-pushed via Windows Update on Win10. If absent,
  WebView2 surfaces an error which the user sees inline.
- Settings → Avancés → Serveur extended with the two URL fields.
- Strings.cs: NavLibrary / NavReport / NavDocumentation,
  SettingsReportUrl / SettingsDocsUrl, DocsPlaceholder, WebViewLoadError.
  Sidebar localized in 5 languages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:52:51 +02:00
f62b212fc1 Settings: About goes above Advanced (Advanced is the last block)
Reorders the bottom of the Settings dialog so the "À propos" section
(version + copyright) sits between Langue and the collapsed Advanced
expander. The Expander becomes the very last item, since power-user
plumbing is naturally what the eye should reach last.

DB default name was already set to "proserveapi" in the previous commit
(LocalConfig.cs line 64) — re-confirmed.

Final order:
  1. License de mise à jour       [expanded]
  2. Langue                        [expanded]
  3. À propos                      [expanded]
  4. ▸ Paramètres avancés          [collapsed]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:41:43 +02:00
f86895553a Settings: collapse advanced sections + dedicated About + default DB name
- DB default name: "proserve" → "proserveapi" (matches the ASTERION install
  script that provisions XAMPP on client machines).
- Settings dialog reorganized:
   1. License de mise à jour     [expanded] ← business-critical
   2. Langue                     [expanded] ← user preference
   3. ▸ PARAMÈTRES AVANCÉS       [collapsed by default]
        contains Server, Installation, Cache, Database, Logs
   4. À PROPOS                   [expanded] ← version + copyright
- The Expander hides plumbing the casual user shouldn't touch (server URL,
  install root, MySQL config, cache directory) but keeps it one click away
  for power users / support diagnostics.
- About card kept always-visible because version info is what support asks
  for first when troubleshooting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:36:57 +02:00
1473e5185c v0.10.0 — DB migration support
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>
2026-05-03 08:29:02 +02:00
333eb24db6 LicenseDetailsDialog: header wraps long subtitles + auto-resize
The « Update license expired » header was truncating the « Expired on X.
You can still download versions… » subtitle: no TextWrapping on the
TextBlock and the inner StackPanel had no horizontal width constraint.

Switched the icon-and-text layout from a horizontal StackPanel to a
2-column Grid (Auto + *) so the text column expands to the Border width.
TextWrapping="Wrap" on title + subtitle, icon now top-aligned to follow
the wrap. Window switched to SizeToContent="Height" with MinHeight=480
so it grows when the localized message wraps to 3+ lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:25:56 +02:00
750bb8cfc9 Settings: Database section with connection params, test, and replay button
New « BASE DE DONNÉES (XAMPP / MySQL) » section in Paramètres covering:

- Host / Port / User / Password / Database — defaults match a fresh XAMPP
  install (localhost:3306, root, empty password, "proserve"). Password is
  DPAPI-encrypted in config.json (same scheme as the license key).
- « Tester » button — opens a fresh MySqlConnection and runs SELECT 1.
  Shows ✓ OK or the connection error inline.
- « Appliquer automatiquement les migrations à l'install » checkbox — opt-in
  for the post-install migration step. Default true.
- « 🔁 Rejouer les migrations » button — manually re-runs ApplyMigrationsAsync
  on the latest installed version. Useful when the post-install run failed
  (XAMPP was off) or after a dev added a new SQL file. Live status « 3/5 :
  0042_add_index.sql » + final « ✓ N applied, M skipped » or « ✗ failure ».
- Hint paragraph below explaining the _migrations/ convention.

SettingsViewModel
- Pulls IDatabaseMigrationService and IInstallationRegistry via DI.
- Save() now also persists the DB block, encrypting the password before write.
- TestDbAsync temporarily swaps the in-memory config so the service sees the
  values being typed (without persisting until Save).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:25:32 +02:00
6f8a320d69 DB migrations: bundled SQL applied automatically after install
Each PROSERVE ZIP can now ship a _migrations/ subfolder with versioned SQL
scripts. The launcher applies them right after extraction, in the same
install session as the binary copy, so the user never lands on a PROSERVE
build whose schema doesn't match its code.

Service (PSLauncher.Core/Migrations)
- IDatabaseMigrationService + DatabaseMigrationService (MySqlConnector,
  BSD-licensed). Each .sql runs in a transaction; on failure the DB
  state is rolled back and the install completes (files only) but the
  user is warned to fix the connection / replay later.
- Tracking table _launcher_migrations (filename, applied_at, checksum,
  duration_ms) — same model as Flyway / Doctrine. Already-applied scripts
  are skipped on subsequent installs. Modified scripts trigger a warning
  log without blocking.
- Custom SQL splitter that respects strings/comments/backticks so a single
  .sql file can contain multiple statements separated by `;`.
- DatabasePasswordProtector: DPAPI CurrentUser scope for the MySQL password
  in config.json (same protection as the license key).

Config (PSLauncher.Models/LocalConfig.cs)
- New DatabaseConfig section: Host=localhost, Port=3306, User=root, empty
  password, Database=proserve, AutoApplyMigrations=true. Defaults match a
  fresh XAMPP install. Override via Settings (next commit).

Install pipeline (MainViewModel)
- After ZipInstaller.InstallAsync and before declaring the install complete,
  if AutoApplyMigrations and a _migrations/ folder exists, run
  ApplyMigrationsAsync with progress reporting (per-file %, filename in
  footer). Failure shows MsgMigrationFailed dialog explaining XAMPP must
  be running and pointing to Settings → Database for connection params.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:21:53 +02:00
48d601176d admin/licenses : per-machine view with individual remove
- New action `remove_machine` releasing a single (license_id, machine_id) slot.
- Pre-fetches all machines in one query and groups by license_id (no N+1).
- Machines column "X / Y" is now clickable: opens an inline expandable row
  showing each machine on that license — truncated SHA-256 ID with full hash
  in tooltip, machine_label, first_seen, last_seen with a "stale" warning
  badge for slots not seen in >30 days, plus a per-row "Libérer" button.
- Existing "Libérer machines" button kept but renamed "Libérer toutes" with
  a beefier confirmation that hints at the per-row alternative.

Replaces the all-or-nothing reset workflow with surgical control: when one
user changed PCs you can free their old slot without touching their colleagues'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:57:01 +02:00
7fb02e4945 v0.9.0 — DL UX: cancel/restart confirmations, auto-revalidate license, fix progress refresh
UX
- Red "Annuler" button next to "↻ Reprendre" on rows with a partial download.
  Confirmation MessageBox before discarding the .partial + state.json.
- "Recommencer depuis zéro" entry in the row's "..." context menu, visible
  only when a partial exists.
- Cancel button visibility tied to a new ShowRestartFromZero property
  (HasResumableDownload && State == AvailableIdle): hides during active DL
  to avoid colliding with the inline cancel.
- ResumableBytes refreshed from state.json after cancel/error so the
  "Reprendre (X%)" label reflects the actual percentage reached, not the
  stale value from launcher startup.
- MainWindow.Closing prompts for confirmation if a download is in progress
  (HasActiveDownload), so an accidental ✕ doesn't waste a 14 GB transfer.
- "🔧 Préparation du téléchargement v…" status set immediately on click and
  kept visible during HEAD/SetLength so the user knows something's happening
  before the first byte. ProgressDetail wired with NotifyPropertyChangedFor
  (FooterText) so download speed shows during DL and not just at install
  transition.

Auto-revalidation
- CheckForUpdatesAsync now runs RefreshLicenseFromServerAsync first: every
  startup auto-check + every manual "Vérifier les MAJ" click pulls the
  latest license state from /api/license/validate. Date changes / revocations
  done in the backoffice propagate to clients without waiting for the 100-day
  cache to expire. Silent fallback to cached state if offline.

Version bumped to 0.9.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:16:14 +02:00
07d58a8854 Licensing: 100-day offline cache with anti-rollback + cached server status
- CachedValidityDays bumped 7 → 100 days. Covers the "user goes online once
  every 3 months for updates" use case without repeatedly nagging for re-auth.
- LastValidationAt now uses response.ServerTime (signed Ed25519 by the server)
  rather than DateTime.UtcNow, so the client's local clock can't be used to
  forge a fresh validation date.
- LastSeenUtc monotonic counter, persisted on every launch as max(stored, now).
  Combined with a 1h grace window: if the user rolls their PC clock backward
  to extend the offline cache, the rollback is detected (now < LastSeen - 1h)
  and the cache is invalidated → forced re-validation next time online.
- CachedStatus persists the server-returned status (valid/expired/revoked/
  invalid/machine_limit_exceeded) so a revocation done while the user is
  offline still shows correctly when they next launch with cached data.
  Auto-override to "expired" if entitlement date has passed (handles the
  valid → expired transition without needing a server round-trip).
- ILicenseService.GetDecryptedKey() exposed for the new auto-revalidation flow.

This puts the trust boundary in the right place: cached data is informational
offline, the actual download authorization always re-validates against the
server (the gate to download is /api/download-url/, which checks the live
license state on each call). User can't fake offline-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:15:49 +02:00
9cea07d6be Downloads: parallel multi-segment, sparse pre-alloc, faster SHA-256, URL auto-refresh
DownloadManager
- Parallel multi-segment download (default 8 connections, configurable up to 16)
  with per-segment Range requests. Bypasses OVH/Apache per-connection bandwidth
  throttling — typical speed-up ×4 to ×8 vs single connection.
- Reporter task on a dedicated Task with Interlocked aggregate counter (no lock
  contention with workers). Reports speed/ETA every 250ms even mid-download,
  fixes the "speed only shows at install transition" bug.
- Sparse file pre-allocation via FSCTL_SET_SPARSE before SetLength: no zero-fill,
  no SeManageVolumePrivilege, instant on any disk type. Removes 5-30s of
  preparation lag on HDD.
- HEAD probe skipped (trust manifest size, signed Ed25519). Falls back to
  single-segment if first segment returns 200 instead of 206.
- Resume URL comparison fixed: ignores HMAC querystring (?exp=&sig=) which
  changes per request, compares only host+path. Previously every resume started
  fresh because the old URL never matched the freshly signed one.
- Auto-refresh signed URL on 403/410 mid-DL: SemaphoreSlim with 5s debounce so
  8 simultaneous segment expirations trigger a single /api/download-url/ call.
  Slow-connection users (1 Mbps, 30+ hours for 14 GB) keep downloading
  transparently across multiple TTL cycles.
- Per-version hashAlgorithm:none in manifest skips client SHA-256 verification
  (still relying on Ed25519 manifest signature + HMAC URL).
- DangerButton style (red) added to Theme.xaml for the new cancel-resume action.

IntegrityService
- 16 MiB buffer (was 1 MiB), FileOptions.SequentialScan + Asynchronous,
  IncrementalHash (uses SHA-NI hardware extensions on .NET 8), double-buffering
  to overlap CPU and I/O. Typical 14 GB hash verification: 60-180s → 15-40s.

HttpClient
- MaxConnectionsPerServer=16, EnableMultipleHttp2Connections, HTTP/2 preferred,
  AutomaticDecompression=None (ZIPs are already compressed), 5min pooled
  connection lifetime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:15:27 +02:00
b4f205cbe5 i18n: full pass + culture-aware dates/sizes + Software Update License wording
- Strings.cs : ~80 new keys covering status messages, progress detail
  (DL/extraction/verify), license summary, license details dialog, onboarding
  statuses, settings field labels, restart/cancel/quit confirmations, toasts,
  resume choice, copyright. Strings.FormatSize and Strings.FormatDate /
  FormatLongDate switch units (o/Ko vs B/KB) and pattern (dd/MM/yyyy vs M/d/yyyy
  vs 2026年) by active language.
- License terminology renamed across UI: "License" → "License de mise à jour"
  / "Software Update License" to clarify it gates UPDATES, not Proserve itself.
  Expired/revoked dialogs now spell out "you can still download versions
  released before this date and launch any installed version".
- "🔒 License insuffisante" → "🔒 License de mise à jour requise pour cette
  version" / "Valid update license needed for this version" so users understand
  it's a per-version eligibility check, not a global block.
- All hardcoded FR strings in views (SettingsDialog labels, LicenseDetails
  fields, Onboarding statuses, dialog titles, copyright, window chrome
  tooltips, "Sortie le", etc.) replaced with x:Static loc bindings.
- All FormatSize duplicates (5 places) and date format strings (8 places)
  delegate to Strings helpers — single source of truth for localization.
- Settings dialog: License section moved to the top before Language. It's
  the most important info and conditions what the user can download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:14:57 +02:00
962f5a8ce0 Server: faster releases (cache hashes), per-version skip, longer signed-URL TTL
- gate.php : ETag now derived from size+mtime instead of md5_file($zip).
  Previously the gate hashed the entire 14 GB ZIP on every HEAD/GET call
  (including each of 8 parallel segments) → 30s-5min preparation lag for
  clients before the first byte. Now <1ms per request.
- SignManifest : disk-backed cache for SHA-256 keyed by (path,size,mtime).
  Re-signing 5×14 GB versions used to take ~25 min, now ~1s when nothing
  changed. New "Force re-hash" toggle in admin to ignore the cache.
- versions.php : per-row "🔁 Hash" button to sign a single version, plus
  a "⚙ Hash" dropdown to toggle hashAlgorithm:none for builds where the
  user accepts skipping client-side verification (manifest stays signed
  Ed25519, only the per-ZIP SHA-256 verification is bypassed).
- DownloadUrl.php : signed-URL TTL bumped 1h → 6h to cover slow ADSL users
  who need >1h to finish a 14 GB download.
- .gitignore : track server/builds/.htaccess + gate.php (still ignore the
  actual ZIP/exe binaries).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:14:32 +02:00
0d4f126e3a i18n: localize all MessageBox dialogs
The previous batch only covered XAML strings. The runtime MessageBox
dialogs (errors, confirmations, info popups) stayed hardcoded in
French, breaking immersion for non-FR users.

Added ~15 keys to Strings.cs for the MessageBox bodies and titles:
- error / launch error / confirm / info / patience / language change /
  release notes (titles)
- launch failed / self-update failed / install failed / uninstall
  failed / uninstall confirm with version+folder+size / no release
  notes / fetch failed / clear cache confirm / deactivate license
  (short and detailed) / language restart (bodies)

Some are parametrized (MsgLaunchFailed(detail), MsgUninstallConfirm
(version, folder, size)) so the localized strings interpolate the
runtime values cleanly across all 5 languages.

Replaced every MessageBox.Show in MainViewModel, SettingsViewModel
and LicenseDetailsDialog.xaml.cs to use these keys. The "Échec : "
prefix in the cache-clear error was dropped — the error message alone
is sufficient with Strings.MsgBoxError as the dialog title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:56:52 +02:00
b9ce591d22 ComboBox fixes: clickable center + display the option name, not the record
Two issues with the language picker after the dark restyling:

1. Click on the center of the ComboBox was a no-op — only the arrow
   triggered the dropdown. The previous template had a separate
   ToggleButton in column 1 covering only the arrow region. New
   template wraps the entire body in a single ToggleButton (with its
   own template carrying the border + arrow), and the ContentPresenter
   for the selected text floats on top with IsHitTestVisible="False"
   so clicks pass through to the toggle.

2. Selected item showed "LanguageOption { Code = auto, Name = ... }"
   instead of just the name. C# record types stringify with property
   names by default. Override LanguageOption.ToString() to return Name
   so the SelectionBox falls back to a clean label even though
   DisplayMemberPath="Name" is set on the dropdown items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:44:00 +02:00
5d0284f1db Theme: dark ComboBox to match the rest of the launcher chrome
The native WPF ComboBox style is light grey on white — unreadable on
the dark Settings dialog. Re-template both ComboBox and ComboBoxItem:

- Body: #1A1A20 background (same as other inputs), border with hover
  highlight in Brush.Accent.
- Custom toggle button arrow in secondary text color, brightens on
  hover.
- Popup: Brush.Bg.Card background with border, 4px radius, 300px max
  height, scrollable.
- Items: highlighted hover in Brush.Accent with white foreground
  (same convention as MenuItem). Selected item gets a subtle
  #2C2C32 strip so the current value is visible after closing.

Affects every ComboBox in the app — only one for now (language
picker in Settings) but stays consistent if more get added later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:10:14 +02:00
01a11d5616 i18n: 5 languages (FR/EN/ZH/TH/AR), auto-detect from Windows + Settings picker
Localization infrastructure
---------------------------
PSLauncher.Core/Localization/Strings.cs is a static class exposing each
UI string as a static property. T(fr,en,zh,th,ar) helper switches by the
current language code. ~50 keys cover the visible top-level strings:
top bar, body, status badges, action buttons, "..." menu items, license
badge texts, Settings section headers, Onboarding dialog, Update dialog.

Bindings via x:Static in XAML:
  xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
  Content="{x:Static loc:Strings.ActionLaunch}"

Auto-detection
--------------
LocalConfig gains a Language field defaulting to "auto". Strings.Init()
called at App.xaml.cs OnStartup before any UI:
- "auto" (or unknown code) → reads CultureInfo.CurrentUICulture
  .TwoLetterISOLanguageName, picks the matching supported language,
  falls back to English.
- explicit code → forced.
The chosen culture is then propagated to CurrentCulture / CurrentUICulture
so date/number formats follow.

Settings picker
---------------
SettingsDialog gets a top section "LANGUE" with a ComboBox bound to
SettingsViewModel.AvailableLanguages (Auto / FR / EN / ZH / TH / AR).
On Save, if the language code changed, prompt the user to confirm
restart, spawn `cmd /c timeout 1 & start PSLauncher.exe` and Shutdown
the current process — the new instance picks up the language at
bootstrap.

RTL for Arabic
--------------
Strings.IsRightToLeft is true when lang=="ar". App.xaml.cs sets
window.FlowDirection = RightToLeft on the MainWindow — WPF mirrors the
layout (icons on right, text aligned right).

Translations
------------
Done as best-effort by the assistant. The product wordmark "PROSERVE"
stays untranslated (brand). Logs and debug-level messages remain in
French in code — only user-visible UI is localized. Operator can
refine translations by editing Strings.cs and rebuilding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:02:50 +02:00
9c1b34b041 Compact rows: full-height accent strip with rounded left corners 2026-05-02 12:50:27 +02:00
ddfebb8611 Featured card: round the left edge of the accent strip to match card corners 2026-05-02 12:48:13 +02:00
2e320d2ab5 Brand: install folder uses uppercase PROSERVE v{version}
The product wordmark is rendered uppercase everywhere in the UI
(PROSERVE in Pirulen). Align the install directory naming so it reads
"PROSERVE v1.4.7" instead of "Proserve v1.4.7" — same brand, same case
on disk and in dialog titles.

Changes:
- server/manifest/versions.json: installFolderTemplate updated for both
  existing entries.
- server/admin/versions.php: default template for new versions added
  via the backoffice form.
- src/PSLauncher.Models/RemoteManifest.cs: default fallback for the
  property when missing from JSON.
- src/PSLauncher.App/Views/MainWindow.xaml + dialogs + ViewModel
  toasts: UI strings now read "PROSERVE v..." consistent with the brand.

InstallationRegistry's regex was already RegexOptions.IgnoreCase, so
existing user installs in "Proserve v..." folders keep working
(case-insensitive on Windows filesystems anyway). Re-installing an
older version after the change re-creates the folder with the new
case — Windows is case-preserving but case-insensitive, so launching
remains identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:46:26 +02:00
7a12153343 LaunchVersion: restore launcher window when Proserve exits
After clicking Lancer, the launcher minimizes to the taskbar so Proserve
has the foreground. Add an Exited event handler on the launched Process
that brings the launcher back from the taskbar once the game closes.

EnableRaisingEvents is required (false by default) to receive Exited.
The handler runs on a worker thread so we marshal back to the UI via
Dispatcher.Invoke before touching WindowState. A brief Topmost=true /
false toggle nudges the window to the foreground without permanently
locking it on top.

Wrapped in try/catch — some ShellExecute spawns return a Process with
limited access (no event support), in which case we just log and let
the user click the taskbar icon manually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:41:54 +02:00
8988d130ac Single-instance launcher + tolerant build pipeline
Single-instance
---------------
Two PSLauncher.exe instances ran in parallel during the user's last
release build, locking the repo-root copy and breaking the publish.
Two-layer protection:

1. App.xaml.cs uses a Global\ named Mutex (UUID-based key) created at
   OnStartup. If the mutex is already held, the second instance
   PostMessages a registered Windows message (PSLAUNCHER_BRING_TO_FRONT)
   to HWND_BROADCAST and Shutdown()s immediately.

2. MainWindow hooks WndProc via SourceInitialized + HwndSource.AddHook;
   when it sees the broadcast, it restores from minimized, calls
   ShowWindow(SW_RESTORE) + Activate() + SetForegroundWindow so the
   already-running instance pops to the user's foreground.

Net result: clicking the launcher icon a second time pops the existing
window instead of starting a duplicate process.

Tolerant build pipeline
-----------------------
- Both csproj post-publish copy targets now have
  ContinueOnError="WarnAndContinue". A locked PSLauncher.exe at the
  repo root no longer fails the entire publish — the binary still
  exists in bin\Release\...\publish\ and the user gets a warning
  instead of an error.

- All four .bat scripts (build-launcher / build-updater / build-all /
  build-installer) now run `taskkill /F /IM PSLauncher.exe /T` and the
  same for PSLauncher.Updater.exe before the publish step. This
  defensively closes any stray instance left behind by previous tests
  so the build pipeline is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:31:11 +02:00
f5f1fe4d36 docs: PowerPoint deck for internal admin + end-user guide, embedded in installer
Two presentations generated from pptxgenjs scripts in docs/, both using
the launcher's dark palette (black bg, #161B23 cards, #3B82F6 blue
accent for internal, #16A34A green accent for the user guide):

- PS_Launcher-Documentation-Interne.pptx (17 slides)
  Cover, sommaire, architecture client/serveur/MySQL, setup OVH initial,
  config.php, génération secrets, backoffice (Dashboard / Licenses /
  Versions / Launcher / Audit), workflow release Proserve, workflow
  release launcher, security (Ed25519 / HMAC / DPAPI), file locations
  + logs, troubleshooting, closing.

- PS_Launcher-Guide-Utilisateur.pptx (14 slides)
  Cover, présentation du launcher, prérequis Windows, installation
  setup.exe (avec gestion SmartScreen), activation license PRSRV-,
  bibliothèque (featured + autres), lancer une version, installer une
  nouvelle version, suivi du DL avec resume, menu ⋯ par version,
  paramètres ⚙, auto-update du launcher, FAQ (license expirée /
  offline / changement PC / SHA-256 KO), support.

Both decks use cards with colored left-accent strips, numbered step
flows, code blocks (Consolas on #0A0E14), and consistent footer
branding "© 2026 ASTERION VR".

installer/PSLauncher.iss now copies the user guide into {app}\docs\
and creates a "Guide utilisateur" Start Menu shortcut next to the
launcher. The internal doc stays in the repo, never shipped to clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:11:35 +02:00
2dc9c98d1e Bump version 0.7.0 / 0.5.0 → 0.8.0 (synced across launcher, updater, installer)
After the auto-update test loop the App was at 0.7.0 and Updater /
Inno Setup were still at 0.5.0. Sync everything to 0.8.0 so a single
version covers the recent batch of branding + UX changes:

- Default installRoot is C:\ASTERION_VR
- ASTERION favicon (window icons + .exe icon + Setup icon)
- ASTERION wordmark left of PROSERVE in the top bar
- Centered, readable "© 2026 ASTERION VR — Tous droits réservés" pill
- Window control hover in blue (was unreadable yellow)
- Background image anchored bottom-right so the brand mark doesn't
  get cropped on resize or hidden by the download footer
- Launcher window minimizes when a Proserve version is launched

Next time the operator pushes a release, "Définir" 0.8.0 in the
backoffice → upload PSLauncher-0.8.0.exe → click the blue Sync.
Existing 0.7.0 launchers will then auto-update to 0.8.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:01:55 +02:00
89f039e355 UI: ASTERION logo left of PROSERVE wordmark + minimize on launch
Brand mark
----------
Resources/logo-asterion-white.png embedded as a WPF Resource. Placed
in the top-bar StackPanel before the PROSERVE Pirulen wordmark, at
36px height, with HighQuality bitmap scaling and SnapsToDevicePixels
to keep the white logo crisp against the dark chrome.

Minimize on launch
------------------
Once a Proserve version is started via _processLauncher.Launch(),
the MainWindow drops to WindowState.Minimized so it doesn't sit on
top of the game. The launcher stays running (so the user can come
back to install / switch / see the badge license), just out of the way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:59:03 +02:00
661c464b7a Background: anchor logo to bottom-right, never overlap the footer
Two related issues with the background bitmap:

1. The footer (download progress bar) appeared on top of the image,
   cropping the bottom strip — including the ASTERION VR mark in the
   bottom-right corner of Background.png.

2. UniformToFill scales-and-crops to maintain aspect ratio, but with
   the previous setup the crop was centered, so resizing the window
   wider/narrower silently pushed the bottom-right logo off-screen.

Fix:
- The bitmap now lives in a Rectangle restricted to Grid.Row="1"
  (body only). When the footer appears at Row 2, it sits below the
  body without overlapping the image.
- Switch from <Image Stretch="UniformToFill"> to
  <Rectangle><Fill><ImageBrush AlignmentX="Right" AlignmentY="Bottom"
  Stretch="UniformToFill" /></Fill></Rectangle>. The crop now
  happens on the LEFT and TOP edges, keeping the logo's corner pinned
  to the bottom-right and visible regardless of window proportions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:54:20 +02:00
c27066ffe4 Theme: window control buttons hover in blue, not yellow-white
The min/max button hover was set to #FFFFFF22, intended as "white at
13% alpha". WPF Color literals are AARRGGBB though, not RRGGBBAA, so
that's actually opaque red+green+a-touch-of-blue → yellow-ish white,
unreadable on the dark chrome.

Switch to Brush.Accent (#3B82F6) on hover with white foreground,
matching the close button red on hover (#E81123) — both controls now
have clear, distinct hover states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:50:59 +02:00
3c31d1ea8a Copyright: center the floating pill at the bottom of the body 2026-05-02 11:49:55 +02:00
8ebde15535 Copyright: readable pill with proper "© 2026 ASTERION VR — All rights reserved"
The previous copyright was a faded TextBlock at Opacity 0.7 with the
secondary grey color, sitting directly on top of the variable-
luminance background image. Hard to read on the bright spots of the
ASTERION VR engraving.

Wrap it in a Border with #A0000000 background (~63% opacity black)
and CornerRadius=10 — same pill style as the license badge — so the
text reads cleanly regardless of what's behind. Foreground is now
plain white instead of the secondary grey.

Text changed from the terse "© ASTERION VR" to the proper convention:
- UI: "© 2026 ASTERION VR — Tous droits réservés" (matches the
  French-language UI in MainWindow + Settings)
- Assembly metadata (Properties → Détails on the .exe): English form
  "© 2026 ASTERION VR — All rights reserved"

Same change applied to PSLauncher.Updater.csproj.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:48:25 +02:00
dfad967eae branding: ASTERION VR favicon + copyright everywhere
Icon
----
src/favicon64.jpg converted to favicon.ico (single-resolution 64x64)
via PowerShell + System.Drawing.Icon.FromHandle. The .ico is now an
EmbeddedResource of PSLauncher.App and referenced as:
- <ApplicationIcon> on PSLauncher.App.csproj → .exe icon in
  Explorer / taskbar / Alt-Tab
- Window.Icon on every WPF window: MainWindow, SettingsDialog,
  OnboardingDialog, LicenseDetailsDialog, UpdateAvailableDialog,
  ReleaseNotesViewerDialog, LauncherUpdateDialog
- <ApplicationIcon> on PSLauncher.Updater.csproj → updater also
  carries the brand icon
- SetupIconFile in installer/PSLauncher.iss → the Inno Setup .exe
  installer shows the icon too

Copyright
---------
- <Company>, <Product>, <Copyright> assembly attributes set in
  both csprojs → properties dialog on the .exe shows "© ASTERION VR".
- MainWindow body has a discreet floating "© ASTERION VR" text in the
  bottom-right, mirroring the "Vérifier les MAJ" button on the left.
  Opacity 0.7 + secondary color so it doesn't compete with the cards.
- SettingsDialog "Logs & Application" section gains a
  "© ASTERION VR — Tous droits réservés" line under the version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:44:28 +02:00
3484c23683 ConfigStore: default installRoot to C:\ASTERION_VR for client installs
Previous default fell back to %UserProfile%\ASTERION_VR which is
fine for dev but unconventional for end users; the standard
deployment path is plainly C:\ASTERION_VR. New first-launch behavior:

1. If a sibling ASTERION_VR exists next to the exe (portable / dev
   layout), prefer it — keeps the development workflow intact.
2. Otherwise, default to C:\ASTERION_VR. The folder is created on
   the first install (Directory.CreateDirectory in ZipInstaller
   handles non-existent parents).

Existing users with an explicit installRoot in their config.json are
unaffected — only fresh configs (or empty installRoot) hit this path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:38:07 +02:00
e7d4b7a04c Add per-module build scripts at repo root
build-launcher.bat: publishes PSLauncher.App only (Release single-file).
build-updater.bat:  publishes PSLauncher.Updater only.
build-all.bat:      both, sequentially, fast-fail on the first error.

All three pause at the end so a double-click from Explorer shows the
result before the window closes. The existing build-installer.bat
(launcher + updater + Inno Setup .exe) stays as the full release
pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:34:23 +02:00
9cb0502338 LicenseService: signed URL via ?key= instead of Authorization header
Apache FastCGI on OVH mutualisé strips Authorization headers before
PHP sees them. The mod_rewrite [E=HTTP_AUTHORIZATION:%1] workaround
in .htaccess does not survive on this hosting profile (probably
because rewrite runs after the FastCGI handler accepts the request).
The endpoint already accepts ?key= as a fallback path, so use that
from the client. HTTPS still protects the key on the wire and the
launcher never logs URLs containing the key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:29:08 +02:00
466755ddc2 DownloadUrl: tolerate Apache FastCGI stripping the Authorization header
OVH mutualisé serves PHP via FastCGI which strips the Authorization
header by default for security. The user's curl test of GET
/api/download-url/{ver} returned 401 even with -H "Authorization:
Bearer <key>" because $_SERVER['HTTP_AUTHORIZATION'] was empty server-side.

Fix on two layers:

1. .htaccess at root re-injects the header into the Apache env so PHP
   can read it via the standard $_SERVER variable. The mod_rewrite
   one-liner `RewriteRule ^ - [E=HTTP_AUTHORIZATION:%1]` is the de-
   facto FastCGI workaround.

2. DownloadUrl.php now reads from any of: $_SERVER['HTTP_AUTHORIZATION'],
   $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] (passthrough variant),
   apache_request_headers(), getallheaders(). Belt and braces — works
   regardless of host config. Falls back to ?key= as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:26:38 +02:00
6da628d687 gitignore: also exclude .bak backup files at repo root 2026-05-02 11:21:48 +02:00
03483d02e1 launcher.php: fix PHP 8 fatal in saveManifest
`usort($manifest['versions'] ?? [], ...)` worked in PHP 7 but is a
fatal error in PHP 8 — usort requires its first arg by reference and
the null-coalesce operator produces an rvalue, not a variable. The
existing versions.php didn't trigger it because it sorts
$manifest['versions'] directly. Fixed launcher.php to guard with an
isset/is_array check and only sort when there's actually a versions
array.

Triggered by clicking "Définir" in the new Launcher page → 500 page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:21:29 +02:00
c24e67f560 Bump app version 0.5.0 -> 0.6.0 for the self-update test loop
The auto-update test was looping: the manifest declared launcher
v0.6.0 but the binary uploaded as PSLauncher-0.6.0.exe was still
internally v0.5.0 (same file, just renamed). After swap, the new
launcher's AssemblyVersion still reported 0.5.0, the manifest still
advertised 0.6.0, and the prompt fired on every restart.

Bump <Version> / <AssemblyVersion> / <FileVersion> to 0.6.0 so the
freshly published PSLauncher.exe genuinely identifies as 0.6.0. After
the swap, IsNewerThanCurrent will return false and the prompt will
stop reappearing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:06:51 +02:00
5411f607b6 Updater: file logging + relaunch fallback to direct Process.Start
The updater was a black box on failure (no console window, errors went
to a discarded stderr). When the user reported "launcher closes but
doesn't relaunch" there was nothing to inspect.

Add a log sink at %LocalAppData%/PSLauncher/logs/updater.log with
simple 256 KiB rotation. Every step (args parsed, PID wait, target
unlock, backup, copy, zone-strip, relaunch) writes a timestamped
line, plus the FATAL stack trace on uncaught exceptions.

Also strip the file's :Zone.Identifier ADS after copy. Internet-
downloaded exes carry this Mark-of-the-Web stream and Windows can
silently refuse to launch them via explorer.exe with no visible
error.

Relaunch becomes two-tier: try `explorer.exe "<target>"` for
de-elevation; if that fails or nothing spawns, fall back to direct
Process.Start with UseShellExecute=true. Better to inherit admin
than not relaunch at all.

ParseArgs strips surrounding quotes from --target/--source values
since the launcher now wraps paths in quotes (necessary for
UseShellExecute=true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:04:42 +02:00
fed5be5916 gitignore: stop tracking built artifacts (PSLauncher-X.Y.Z.exe, installer output)
Last commit accidentally captured two large binaries that landed in the
working tree during the self-update test prep:
- PSLauncher-0.6.0.exe at repo root
- installer/output/PSLauncher-Setup-0.5.0.exe

Neither belongs in version control. Add /PSLauncher-*.exe and
/installer/output/ to .gitignore and `git rm --cached` the existing
entries so the next push doesn't reupload them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 10:57:56 +02:00
f2a1de9aac Self-update: UAC-aware, de-elevated relaunch, user-mode installer
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>
2026-05-02 10:57:32 +02:00
56069f606d backoffice: dedicated Launcher page, scope-aware Sync
The launcher and the game versions are different release cadences
managed by the same operator. Mixing them on one page muddied the
workflow. Split them into two top-level nav entries.

SignManifest::run(string $scope)
--------------------------------
- 'all' (default, used by CLI tools/sign-manifest.php) — unchanged.
- 'versions' — only re-hashes the Proserve ZIPs and bumps `latest`.
- 'launcher' — only re-hashes the launcher EXE.
The Ed25519 sign step always runs at the end so the manifest stays
verifiable. Selectively hashing avoids unrelated noise (e.g. mass-hash
14 GB ZIPs when all you wanted was to update the launcher exe).

admin/launcher.php (new page)
-----------------------------
Self-contained page with the launcher state, Set/Remove forms, the
blue "🔁 Hasher le launcher + signer" button, and a list of the .exe
files present in builds/launcher/. Workflow doc inline.

admin/versions.php
------------------
Cleaned up: launcher card and its set_launcher / remove_launcher /
sync_launcher actions removed. The remaining global Sync button is
relabeled and now triggers scope='versions' (only Proserve ZIPs).

Layout::navHtml gains a "Launcher" item between Versions and Audit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 10:53:14 +02:00
380 changed files with 61935 additions and 850 deletions

36
.gitignore vendored
View File

@@ -28,14 +28,44 @@ packages/
# Large game build (sample only — should not be in repo)
ASTERION_VR/
# Sources des releases PROSERVE en attente de zip/upload :
# on ignore tout sauf les _migrations/ (SQL source-of-truth de la DB).
# Le _report/ est un build artifact (Vue/React minifié) maintenu ailleurs.
# Les binaires UE5 sont gigaoctets, à pousser uniquement via SFTP.
version/*
!version/.gitkeep
!version/*/
version/*/*
!version/*/_migrations/
version/*/_migrations/.htaccess
# Server-side secrets — never commit (config.example.php sert de template)
server/api/config.php
# Le contenu de server/builds/ est ignoré (gros ZIPs / launcher.exe), mais on
# garde le code (.htaccess, gate.php, .gitkeep) sous version.
server/builds/*
!server/builds/.gitkeep
!server/builds/.htaccess
!server/builds/gate.php
!server/builds/launcher/.gitkeep
# Plans
.claude/
# Binaires copiés par le post-publish à la racine du repo
/PSLauncher.exe
/PSLauncher.Updater.exe
# Binaires copiés par le post-publish à la racine du repo (glob unique qui
# couvre PS_Launcher.exe, PS_Launcher.Updater.exe, PS_Launcher-X.Y.Z.exe,
# PS_Launcher_X.Y.Z.exe — séparateur tiret ou underscore — et les .bak).
/PS_Launcher*.exe
/PS_Launcher*.bak
# Sortie de l'installeur Inno Setup
/installer/output/
# WebView2 user data folder créé à côté de l'exe au runtime (cache, cookies,
# sync data Edge Chromium). Peut faire plusieurs Mo. Le pattern * couvre les
# 2 noms historiques (PSLauncher.exe.WebView2, PS_Launcher.exe.WebView2) et
# tout build de test ponctuel avec un autre nom d'exe.
*.exe.WebView2/
# Fichiers de lock Office (Word/PowerPoint) commençant par ~$
~$*

36
build-all.bat Normal file
View File

@@ -0,0 +1,36 @@
@echo off
REM Build complet : launcher + updater (sans creer l'installeur).
REM Pour l'installeur Inno Setup, utiliser build-installer.bat.
setlocal
set "REPO=%~dp0"
echo.
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
echo.
echo ==^> dotnet publish PSLauncher.App (Release single-file)
echo.
dotnet publish "%REPO%src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
if %ERRORLEVEL% NEQ 0 goto :fail
echo.
echo ==^> dotnet publish PSLauncher.Updater (Release single-file)
echo.
dotnet publish "%REPO%src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo
if %ERRORLEVEL% NEQ 0 goto :fail
echo.
echo [OK] Les deux exes sont disponibles a la racine du repo :
echo %REPO%PS_Launcher.exe
echo %REPO%PS_Launcher.Updater.exe
echo.
pause
endlocal & exit /b 0
:fail
echo.
echo [ECHEC] Build echoue avec le code %ERRORLEVEL%.
echo.
pause
endlocal & exit /b %ERRORLEVEL%

View File

@@ -12,6 +12,10 @@ REM powershell -ExecutionPolicy Bypass -File installer\build-installer.ps1 -IS
setlocal
set "SCRIPT_DIR=%~dp0"
REM Termine les eventuelles instances en cours qui verrouilleraient les .exe
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%installer\build-installer.ps1" %*
set "EXITCODE=%ERRORLEVEL%"

27
build-launcher.bat Normal file
View File

@@ -0,0 +1,27 @@
@echo off
REM Build du launcher principal (PS_Launcher.exe).
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PS_Launcher.exe (~78 Mo single-file self-contained)
setlocal
set "REPO=%~dp0"
echo.
echo ==^> Termine les eventuelles instances de PS_Launcher qui verrouilleraient les .exe
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
echo.
echo ==^> dotnet publish PSLauncher.App (Release single-file)
echo.
dotnet publish "%REPO%src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
set "EXITCODE=%ERRORLEVEL%"
echo.
if %EXITCODE% NEQ 0 (
echo [ECHEC] Build du launcher echoue avec le code %EXITCODE%.
) else (
echo [OK] PS_Launcher.exe disponible a la racine du repo :
echo %REPO%PS_Launcher.exe
)
echo.
pause
endlocal ^& exit /b %EXITCODE%

137
build-proserve-zip.bat Normal file
View File

@@ -0,0 +1,137 @@
@echo off
REM ========================================================================
REM build-proserve-zip.bat
REM Crée le ZIP de release d'une version PROSERVE prêt à uploader sur OVH.
REM
REM Usage :
REM build-proserve-zip.bat 1.5.3
REM
REM Le script :
REM 1. Lit version\proserve-X.Y.Z\ (binaires UE5 + _migrations\ + _report\)
REM 2. Compresse en version\proserve-X.Y.Z.zip
REM 3. Affiche taille + SHA-256 (à recopier dans le backoffice si tu veux
REM eviter le hash automatique côté serveur, sinon laisse le serveur le
REM faire au moment du « Hasher + signer »)
REM
REM Outils :
REM - 7-Zip si installé (multi-threading, ~3-5x plus rapide sur 14 Go)
REM - PowerShell Compress-Archive en fallback (mono-thread, lent)
REM
REM Sortie : version\proserve-X.Y.Z.zip à uploader vers
REM www/PS_Launcher/builds/proserve-X.Y.Z.zip via SFTP.
REM ========================================================================
setlocal EnableDelayedExpansion
if "%~1"=="" (
echo.
echo Usage : build-proserve-zip.bat ^<version^>
echo Exemple : build-proserve-zip.bat 1.5.3
echo.
echo Versions disponibles dans version\ :
for /D %%D in ("%~dp0version\proserve-*") do echo %%~nxD
echo.
exit /b 1
)
set "VERSION=%~1"
set "REPO=%~dp0"
set "SRC=%REPO%version\proserve-%VERSION%"
set "OUT=%REPO%version\proserve-%VERSION%.zip"
if not exist "%SRC%" (
echo [ERREUR] Dossier source introuvable : %SRC%
echo Liste des versions disponibles :
for /D %%D in ("%REPO%version\proserve-*") do echo %%~nxD
exit /b 1
)
REM Sanity-check que les sous-dossiers attendus sont là
if not exist "%SRC%\PROSERVE_UE_5_5.exe" (
echo [WARN] %SRC%\PROSERVE_UE_5_5.exe absent — c'est normal ? Continuer dans 5s, Ctrl+C pour annuler...
timeout /t 5 >nul
)
if not exist "%SRC%\_migrations" (
echo [WARN] %SRC%\_migrations\ absent — pas de migrations DB dans cette release.
)
if not exist "%SRC%\_report" (
echo [WARN] %SRC%\_report\ absent — pas d'outil Report dans cette release.
)
if exist "%OUT%" (
echo [INFO] Suppression du zip existant : %OUT%
del /Q "%OUT%"
)
REM ----- 7-Zip si disponible -----
set "SEVENZIP="
if exist "C:\Program Files\7-Zip\7z.exe" set "SEVENZIP=C:\Program Files\7-Zip\7z.exe"
if exist "C:\Program Files (x86)\7-Zip\7z.exe" set "SEVENZIP=C:\Program Files (x86)\7-Zip\7z.exe"
echo.
echo ========================================================================
echo Compression de proserve-%VERSION% (peut prendre 5-30 min selon la taille)
echo Source : %SRC%
echo Sortie : %OUT%
echo ========================================================================
echo.
if defined SEVENZIP (
echo [INFO] Utilisation de 7-Zip multi-thread (rapide)
REM -tzip format ZIP standard ^(compatible avec ce que le launcher attend^)
REM -mx=5 compression normale ^(mx=9 ultra serait 2x plus lent pour ~5%% de gain^)
REM -mmt=on multi-thread
REM -bsp1 progress sur stderr
REM Le * en fin de chemin source = on zippe le CONTENU du dossier, pas le dossier lui-meme
"%SEVENZIP%" a -tzip -mx=5 -mmt=on -bsp1 "%OUT%" "%SRC%\*"
set "EXITCODE=!ERRORLEVEL!"
) else (
echo [INFO] 7-Zip non trouve, fallback sur PowerShell Compress-Archive ^(lent^).
echo Pour accelerer, installe 7-Zip depuis https://www.7-zip.org/
echo.
powershell -NoProfile -ExecutionPolicy Bypass -Command ^
"Compress-Archive -Path '%SRC%\*' -DestinationPath '%OUT%' -CompressionLevel Optimal -Force"
set "EXITCODE=!ERRORLEVEL!"
)
if !EXITCODE! NEQ 0 (
echo.
echo [ECHEC] Compression echouee avec le code !EXITCODE!.
exit /b !EXITCODE!
)
if not exist "%OUT%" (
echo [ECHEC] Le fichier %OUT% n'a pas ete cree.
exit /b 1
)
echo.
echo ========================================================================
echo [OK] Zip cree avec succes
echo ========================================================================
echo.
echo Fichier : %OUT%
for %%I in ("%OUT%") do (
set "SIZE=%%~zI"
echo Taille : !SIZE! octets ^(~%%~zI bytes^)
)
echo.
echo Calcul du SHA-256 ^(peut prendre 30s-2min selon la taille^)...
powershell -NoProfile -Command ^
"$h = (Get-FileHash '%OUT%' -Algorithm SHA256).Hash.ToLower(); Write-Host SHA-256 : $h"
echo.
echo ========================================================================
echo Prochaines etapes :
echo 1. SFTP upload vers : www/PS_Launcher/builds/proserve-%VERSION%.zip
echo 2. Backoffice -^> Versions -^> verifier que l'entree v%VERSION% existe
echo ^(sinon ajoute-la avec date d'expiration min_license_date^)
echo 3. Cliquer ligne v%VERSION% -^> bouton "Hasher cette version"
echo ^(ou bouton global "Hasher les versions + signer"^)
echo 4. Le manifest est re-signe Ed25519, les clients verront la version
echo au prochain "Verifier les MAJ".
echo ========================================================================
echo.
endlocal
pause

28
build-updater.bat Normal file
View File

@@ -0,0 +1,28 @@
@echo off
REM Build de l'updater (PS_Launcher.Updater.exe).
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PS_Launcher.Updater.exe (~34 Mo single-file)
REM L'updater accompagne PS_Launcher.exe — il doit etre dans le meme dossier.
setlocal
set "REPO=%~dp0"
echo.
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
taskkill /F /IM PS_Launcher.exe /T >nul 2>&1
taskkill /F /IM PS_Launcher.Updater.exe /T >nul 2>&1
echo.
echo ==^> dotnet publish PSLauncher.Updater (Release single-file)
echo.
dotnet publish "%REPO%src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo
set "EXITCODE=%ERRORLEVEL%"
echo.
if %EXITCODE% NEQ 0 (
echo [ECHEC] Build de l'updater echoue avec le code %EXITCODE%.
) else (
echo [OK] PS_Launcher.Updater.exe disponible a la racine du repo :
echo %REPO%PS_Launcher.Updater.exe
)
echo.
pause
endlocal ^& exit /b %EXITCODE%

Binary file not shown.

Binary file not shown.

26
docs/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Documentation PS_Launcher
Deux documents PowerPoint, l'un pour l'admin/dev (interne), l'autre destiné aux clients finaux.
| Fichier | Public | Contenu |
|---|---|---|
| [PS_Launcher-Documentation-Interne.pptx](PS_Launcher-Documentation-Interne.pptx) | Équipe ASTERION | Architecture, setup OVH, backoffice, workflows release, sécurité, troubleshooting |
| [PS_Launcher-Guide-Utilisateur.pptx](PS_Launcher-Guide-Utilisateur.pptx) | Clients | Installation, activation license, utilisation, FAQ, support |
## Régénérer les documents
Si tu modifies le contenu, regénère les `.pptx` depuis les sources Node :
```powershell
cd docs
$env:NODE_PATH = "$env:APPDATA\npm\node_modules"
node _build_internal.js
node _build_user.js
```
(Pré-requis : Node.js + `npm install -g pptxgenjs`)
## Distribution
- **Doc interne** : reste dans le repo, pas distribuée. Garde-la à jour pour les nouveaux arrivants côté équipe.
- **Guide utilisateur** : embarqué automatiquement par l'installeur Inno Setup dans `{app}\docs\`. Un raccourci « Guide utilisateur » est créé dans le menu Démarrer à l'install.

981
docs/_build_internal.js Normal file
View File

@@ -0,0 +1,981 @@
// Generates PS_Launcher-Documentation-Interne.pptx
const pptxgen = require("pptxgenjs");
const COL = {
bg: "000000",
card: "161B23",
elev: "1E2632",
border: "2A2F3A",
text: "F2F2F2",
muted: "A0A0A8",
accent: "3B82F6",
accentDk: "1F3A66",
green: "16A34A",
greenDk: "0E2A1B",
amber: "F59E0B",
amberDk: "3A2A0E",
red: "EF4444",
redDk: "2A1414",
codeBg: "0A0E14",
};
const F_TITLE = "Calibri";
const F_BODY = "Calibri";
const F_CODE = "Consolas";
const pres = new pptxgen();
pres.layout = "LAYOUT_16x9";
pres.author = "ASTERION VR";
pres.title = "PS_Launcher — Documentation Interne";
pres.company = "ASTERION VR";
const W = 10, H = 5.625;
// ===================== HELPERS =====================
function fillBg(slide) {
slide.background = { color: COL.bg };
}
function addHeader(slide, title, kicker) {
// Top accent bar
slide.addShape(pres.shapes.RECTANGLE, {
x: 0, y: 0, w: W, h: 0.06, fill: { color: COL.accent }, line: { type: "none" }
});
if (kicker) {
slide.addText(kicker.toUpperCase(), {
x: 0.5, y: 0.25, w: W - 1, h: 0.3,
fontFace: F_BODY, fontSize: 11, bold: true,
color: COL.accent, charSpacing: 4, margin: 0
});
}
slide.addText(title, {
x: 0.5, y: kicker ? 0.55 : 0.35, w: W - 1, h: 0.7,
fontFace: F_TITLE, fontSize: 32, bold: true,
color: COL.text, margin: 0
});
// Underline-ish thin separator
slide.addShape(pres.shapes.LINE, {
x: 0.5, y: kicker ? 1.3 : 1.15, w: 1.2, h: 0,
line: { color: COL.accent, width: 2 }
});
}
function addFooter(slide, page) {
slide.addText("PS_LAUNCHER • Documentation Interne • ASTERION VR", {
x: 0.5, y: H - 0.35, w: W - 2, h: 0.25,
fontFace: F_BODY, fontSize: 9, color: COL.muted, margin: 0
});
if (page) {
slide.addText(String(page), {
x: W - 1, y: H - 0.35, w: 0.5, h: 0.25,
fontFace: F_BODY, fontSize: 9, color: COL.muted, align: "right", margin: 0
});
}
}
function addCard(slide, x, y, w, h, options = {}) {
slide.addShape(pres.shapes.RECTANGLE, {
x, y, w, h,
fill: { color: options.fill || COL.card },
line: { color: options.border || COL.border, width: 1 },
});
}
function addAccentCard(slide, x, y, w, h, accentColor) {
addCard(slide, x, y, w, h);
// left accent strip
slide.addShape(pres.shapes.RECTANGLE, {
x, y, w: 0.08, h,
fill: { color: accentColor }, line: { type: "none" }
});
}
function addCodeBlock(slide, x, y, w, h, lines) {
slide.addShape(pres.shapes.RECTANGLE, {
x, y, w, h, fill: { color: COL.codeBg }, line: { color: COL.border, width: 1 }
});
const text = (Array.isArray(lines) ? lines : [lines]).map((line, i, arr) => ({
text: line,
options: { breakLine: i < arr.length - 1 }
}));
slide.addText(text, {
x: x + 0.15, y: y + 0.1, w: w - 0.3, h: h - 0.2,
fontFace: F_CODE, fontSize: 11, color: COL.text, margin: 0,
valign: "top"
});
}
function addBullets(slide, x, y, w, h, items, opts = {}) {
const fontSize = opts.fontSize || 14;
const color = opts.color || COL.text;
const arr = items.map((it, i) => ({
text: it,
options: { bullet: { code: "25A0" }, breakLine: i < items.length - 1, color }
}));
slide.addText(arr, {
x, y, w, h, fontFace: F_BODY, fontSize, color, margin: 0,
paraSpaceAfter: 6
});
}
function addPill(slide, x, y, w, h, label, color) {
slide.addShape(pres.shapes.ROUNDED_RECTANGLE, {
x, y, w, h, fill: { color }, line: { type: "none" },
rectRadius: h / 2
});
slide.addText(label, {
x, y, w, h, fontFace: F_BODY, fontSize: 10, bold: true,
color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
}
// ===================== SLIDE 1: TITLE =====================
{
const s = pres.addSlide();
fillBg(s);
// Big colored band on the right
s.addShape(pres.shapes.RECTANGLE, {
x: 6.5, y: 0, w: 3.5, h: H, fill: { color: COL.accentDk }, line: { type: "none" }
});
// Vertical accent line
s.addShape(pres.shapes.RECTANGLE, {
x: 6.5, y: 0, w: 0.05, h: H, fill: { color: COL.accent }, line: { type: "none" }
});
// Brand label
s.addText("ASTERION VR", {
x: 0.6, y: 0.5, w: 4, h: 0.4,
fontFace: F_BODY, fontSize: 13, bold: true,
color: COL.accent, charSpacing: 8, margin: 0
});
s.addText("PS_Launcher", {
x: 0.6, y: 1.5, w: 6, h: 1,
fontFace: F_TITLE, fontSize: 56, bold: true,
color: COL.text, margin: 0
});
s.addText("Documentation Interne", {
x: 0.6, y: 2.5, w: 6, h: 0.7,
fontFace: F_TITLE, fontSize: 28,
color: COL.muted, margin: 0
});
s.addText("Déploiement, configuration & exploitation", {
x: 0.6, y: 3.2, w: 6, h: 0.5,
fontFace: F_BODY, fontSize: 16, italic: true,
color: COL.muted, margin: 0
});
// Right column big version pill
s.addText("v0.8", {
x: 6.7, y: 1.5, w: 3.2, h: 1.2,
fontFace: F_TITLE, fontSize: 76, bold: true,
color: COL.text, align: "center", margin: 0
});
s.addText("VERSION", {
x: 6.7, y: 2.6, w: 3.2, h: 0.3,
fontFace: F_BODY, fontSize: 11,
color: COL.muted, align: "center", charSpacing: 6, margin: 0
});
// Date
s.addText("Mai 2026", {
x: 6.7, y: 3.6, w: 3.2, h: 0.4,
fontFace: F_BODY, fontSize: 14,
color: COL.text, align: "center", margin: 0
});
// Bottom band
s.addShape(pres.shapes.RECTANGLE, {
x: 0, y: H - 0.5, w: W, h: 0.5, fill: { color: COL.card }, line: { type: "none" }
});
s.addText("Confidentiel — usage interne uniquement", {
x: 0.5, y: H - 0.45, w: W - 1, h: 0.4,
fontFace: F_BODY, fontSize: 11, italic: true,
color: COL.muted, margin: 0
});
}
// ===================== SLIDE 2: SOMMAIRE =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Sommaire");
addFooter(s, 2);
const sections = [
["1", "Architecture", "Vue d'ensemble client-serveur"],
["2", "Setup OVH initial", "MySQL, config.php, clés Ed25519"],
["3", "Tour du backoffice", "Dashboard, Licenses, Versions, Launcher, Audit"],
["4", "Workflow de release", "Proserve & launcher"],
["5", "Gestion des licenses", "Émission, prolongation, révocation"],
["6", "Sécurité", "URLs HMAC, signatures Ed25519, DPAPI"],
["7", "Exploitation", "Logs, fichiers, troubleshooting"],
];
let y = 1.6;
sections.forEach(([num, title, desc]) => {
// Number circle
s.addShape(pres.shapes.OVAL, {
x: 0.5, y, w: 0.5, h: 0.5,
fill: { color: COL.accent }, line: { type: "none" }
});
s.addText(num, {
x: 0.5, y, w: 0.5, h: 0.5,
fontFace: F_TITLE, fontSize: 18, bold: true,
color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
// Title
s.addText(title, {
x: 1.2, y: y - 0.05, w: 4, h: 0.35,
fontFace: F_TITLE, fontSize: 16, bold: true,
color: COL.text, margin: 0
});
// Description
s.addText(desc, {
x: 1.2, y: y + 0.27, w: 6, h: 0.3,
fontFace: F_BODY, fontSize: 12,
color: COL.muted, margin: 0
});
y += 0.55;
});
}
// ===================== SLIDE 3: ARCHITECTURE =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Architecture", "section 1");
addFooter(s, 3);
// Three boxes : Client - HTTPS - OVH
const boxY = 1.8, boxH = 2.2, boxW = 2.7;
// Client
addAccentCard(s, 0.5, boxY, boxW, boxH, COL.green);
s.addText("CLIENT", {
x: 0.7, y: boxY + 0.1, w: boxW - 0.3, h: 0.3,
fontFace: F_BODY, fontSize: 10, bold: true, color: COL.muted, charSpacing: 4, margin: 0
});
s.addText("PSLauncher.exe", {
x: 0.7, y: boxY + 0.4, w: boxW - 0.3, h: 0.4,
fontFace: F_TITLE, fontSize: 18, bold: true, color: COL.text, margin: 0
});
addBullets(s, 0.7, boxY + 0.85, boxW - 0.3, 1.3, [
"WPF / .NET 8",
"License DPAPI locale",
"Cache & logs",
"Updater intégré"
], { fontSize: 11, color: COL.muted });
// Arrow / HTTPS
s.addShape(pres.shapes.LINE, {
x: 3.3, y: boxY + 1.1, w: 0.8, h: 0,
line: { color: COL.accent, width: 2.5 }
});
s.addText("HTTPS", {
x: 3.3, y: boxY + 0.7, w: 0.8, h: 0.3,
fontFace: F_BODY, fontSize: 11, bold: true, color: COL.accent, align: "center", margin: 0
});
// OVH
addAccentCard(s, 4.15, boxY, boxW, boxH, COL.accent);
s.addText("SERVEUR", {
x: 4.35, y: boxY + 0.1, w: boxW - 0.3, h: 0.3,
fontFace: F_BODY, fontSize: 10, bold: true, color: COL.muted, charSpacing: 4, margin: 0
});
s.addText("OVH mutualisé", {
x: 4.35, y: boxY + 0.4, w: boxW - 0.3, h: 0.4,
fontFace: F_TITLE, fontSize: 18, bold: true, color: COL.text, margin: 0
});
addBullets(s, 4.35, boxY + 0.85, boxW - 0.3, 1.3, [
"PHP 8.3 + Apache",
"API REST + backoffice",
"Manifest signé Ed25519",
"Builds ZIPs servis HMAC"
], { fontSize: 11, color: COL.muted });
// Arrow / SQL
s.addShape(pres.shapes.LINE, {
x: 6.95, y: boxY + 1.1, w: 0.8, h: 0,
line: { color: COL.accent, width: 2.5 }
});
s.addText("PDO", {
x: 6.95, y: boxY + 0.7, w: 0.8, h: 0.3,
fontFace: F_BODY, fontSize: 11, bold: true, color: COL.accent, align: "center", margin: 0
});
// MySQL
addAccentCard(s, 7.8, boxY, boxW - 0.5, boxH, COL.amber);
s.addText("BASE", {
x: 8, y: boxY + 0.1, w: boxW - 0.7, h: 0.3,
fontFace: F_BODY, fontSize: 10, bold: true, color: COL.muted, charSpacing: 4, margin: 0
});
s.addText("MySQL OVH", {
x: 8, y: boxY + 0.4, w: boxW - 0.7, h: 0.4,
fontFace: F_TITLE, fontSize: 18, bold: true, color: COL.text, margin: 0
});
addBullets(s, 8, boxY + 0.85, boxW - 0.7, 1.3, [
"licenses",
"license_machines",
"rate_limit",
"audit_log"
], { fontSize: 11, color: COL.muted });
// Footnote
s.addText("Le launcher conserve sa license en cache local (DPAPI scope CurrentUser) et fonctionne offline 7 jours après la dernière validation serveur.", {
x: 0.5, y: 4.4, w: W - 1, h: 0.6,
fontFace: F_BODY, fontSize: 11, italic: true, color: COL.muted, margin: 0
});
}
// ===================== SLIDE 4: SETUP OVH =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Setup OVH initial", "section 2 — première fois uniquement");
addFooter(s, 4);
const steps = [
["1", "Créer la base MySQL", "Manager OVH → Bases de données → Créer.\nNoter host, user, password, nom."],
["2", "Jouer la migration", "PhpMyAdmin OVH → onglet SQL → coller migrations/001_init.sql\n(crée 4 tables : licenses, license_machines, rate_limit, audit_log)"],
["3", "Uploader le code en SFTP", "server/ → www/PS_Launcher/\nRespecter l'arborescence : api/, admin/, manifest/, builds/, tools/, migrations/"],
["4", "Créer api/config.php", "Copier api/config.example.php → api/config.php\nÉditer DSN MySQL, secrets HMAC/JWT, hash bcrypt admin, paire Ed25519"],
];
let y = 1.5;
steps.forEach(([num, title, desc]) => {
addCard(s, 0.5, y, W - 1, 0.85);
// Number
s.addShape(pres.shapes.OVAL, {
x: 0.7, y: y + 0.2, w: 0.45, h: 0.45,
fill: { color: COL.accent }, line: { type: "none" }
});
s.addText(num, {
x: 0.7, y: y + 0.2, w: 0.45, h: 0.45,
fontFace: F_TITLE, fontSize: 16, bold: true,
color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
// Title
s.addText(title, {
x: 1.4, y: y + 0.1, w: 7.5, h: 0.3,
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
});
// Desc
s.addText(desc, {
x: 1.4, y: y + 0.4, w: 7.5, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
y += 0.95;
});
}
// ===================== SLIDE 5: CONFIG.PHP =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "config.php — secrets serveur", "section 2");
addFooter(s, 5);
addCodeBlock(s, 0.5, 1.5, W - 1, 2.6, [
"<?php return [",
" 'base_url' => 'https://asterionvr.com/PS_Launcher',",
" 'db' => [",
" 'dsn' => 'mysql:host=xxx.mysql.db;dbname=xxx;charset=utf8mb4',",
" 'username' => '...', 'password' => '...',",
" ],",
" 'hmac_secret' => '<64 hex chars>', // bin2hex(random_bytes(32))",
" 'ed25519' => [",
" 'private_key_hex' => '<128 hex>', // tools/generate-keypair.php",
" 'public_key_hex' => '<64 hex>', // → idem + Resources/server-pubkey.txt",
" ],",
" 'jwt_secret' => '<64 hex>',",
" 'admin_password_hash' => '$2y$10$...' // password_hash('...')",
"];",
]);
// Tip card
addAccentCard(s, 0.5, 4.3, W - 1, 0.85, COL.amber);
s.addText("⚠ Point critique", {
x: 0.7, y: 4.4, w: 3, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.amber, margin: 0
});
s.addText("public_key_hex doit être recopié à l'identique dans Resources/server-pubkey.txt côté projet C# avant un nouveau publish — sinon le launcher refusera la signature serveur.", {
x: 0.7, y: 4.7, w: W - 1.4, h: 0.5,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
}
// ===================== SLIDE 6: GENERATE KEYS =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Génération des secrets", "section 2");
addFooter(s, 6);
// Two columns
const c1 = 0.5, c2 = 5.2, cw = 4.3;
// Col 1 : Ed25519
s.addText("🔐 Keypair Ed25519", {
x: c1, y: 1.4, w: cw, h: 0.4,
fontFace: F_TITLE, fontSize: 15, bold: true, color: COL.text, margin: 0
});
s.addText("Signe le manifest et la réponse de validation license.", {
x: c1, y: 1.7, w: cw, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
addCodeBlock(s, c1, 2.1, cw, 0.7, [
"$ cd ~/www/PS_Launcher",
"$ php tools/generate-keypair.php",
]);
addBullets(s, c1, 2.95, cw, 1.6, [
"private_key_hex → api/config.php uniquement",
"public_key_hex → api/config.php ET",
" Resources/server-pubkey.txt",
], { fontSize: 11 });
// Col 2 : Admin password
s.addText("🔑 Mot de passe admin", {
x: c2, y: 1.4, w: cw, h: 0.4,
fontFace: F_TITLE, fontSize: 15, bold: true, color: COL.text, margin: 0
});
s.addText("Bcrypt — pour la connexion à /admin/.", {
x: c2, y: 1.7, w: cw, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
addCodeBlock(s, c2, 2.1, cw, 1.3, [
"$ php -r 'echo password_hash(",
" \"<motdepasse>\",",
" PASSWORD_DEFAULT) . \"\\n\";'",
]);
addBullets(s, c2, 3.55, cw, 1.0, [
"Hash dans api/config.php",
" → admin_password_hash",
], { fontSize: 11 });
// hmac/jwt
addCard(s, 0.5, 4.7, W - 1, 0.65);
s.addText("🎲 HMAC & JWT secrets", {
x: 0.7, y: 4.8, w: 4, h: 0.25,
fontFace: F_TITLE, fontSize: 12, bold: true, color: COL.text, margin: 0
});
s.addText("Générer 64 hex chars : php -r \"echo bin2hex(random_bytes(32));\"", {
x: 0.7, y: 5.0, w: W - 1.4, h: 0.3,
fontFace: F_CODE, fontSize: 11, color: COL.muted, margin: 0
});
}
// ===================== SLIDE 7: BACKOFFICE - DASHBOARD =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Backoffice — Dashboard", "section 3 · vue d'ensemble");
addFooter(s, 7);
s.addText("URL : https://asterionvr.com/PS_Launcher/admin/", {
x: 0.5, y: 1.4, w: W - 1, h: 0.3,
fontFace: F_CODE, fontSize: 12, color: COL.accent, margin: 0
});
// KPI grid 5 cells
const kpiW = 1.85, kpiH = 1.0, kpiY = 1.95, gap = 0.05;
const kpis = [
["Licenses actives", "•", COL.green],
["Expirées", "•", COL.amber],
["Révoquées", "•", COL.red],
["Machines vues 30j", "•", COL.text],
["Validations 24h", "•", COL.text],
];
kpis.forEach((kpi, i) => {
const [label, val, color] = kpi;
const x = 0.5 + i * (kpiW + gap);
addCard(s, x, kpiY, kpiW, kpiH);
s.addText(label.toUpperCase(), {
x: x + 0.1, y: kpiY + 0.1, w: kpiW - 0.2, h: 0.3,
fontFace: F_BODY, fontSize: 9, bold: true, color: COL.muted, charSpacing: 2, margin: 0
});
s.addText(val, {
x: x + 0.1, y: kpiY + 0.35, w: kpiW - 0.2, h: 0.55,
fontFace: F_TITLE, fontSize: 32, bold: true, color, margin: 0
});
});
// Liste features
s.addText("Ce que tu y vois", {
x: 0.5, y: 3.2, w: W - 1, h: 0.3,
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
});
addBullets(s, 0.5, 3.5, W - 1, 1.3, [
"Compteurs licenses + machines + validations en temps réel",
"État du dépôt de versions (manifest signé / non signé, version latest)",
"10 dernières lignes de l'audit log avec leur sévérité (validate_ok, expired, revoked, …)",
], { fontSize: 12 });
// Bottom callout
addAccentCard(s, 0.5, 4.85, W - 1, 0.5, COL.accent);
s.addText("✓ Page d'entrée — pas d'action destructive ici, c'est le tableau de bord lecture seule.", {
x: 0.7, y: 4.9, w: W - 1.4, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.text, margin: 0
});
}
// ===================== SLIDE 8: BACKOFFICE - LICENSES =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Backoffice — Licenses", "section 3 · gestion clients");
addFooter(s, 8);
s.addText("Émettre, prolonger, révoquer, libérer les machines occupées.", {
x: 0.5, y: 1.4, w: W - 1, h: 0.3,
fontFace: F_BODY, fontSize: 13, italic: true, color: COL.muted, margin: 0
});
// Workflow boxes
const flow = [
["Émettre", "Formulaire en haut\nNom client, date d'expiration, max machines, notes\n→ Clé PRSRV-XXXX-XXXX-XXXX-XXXX affichée UNE FOIS", COL.green],
["Prolonger", "Bouton Prolonger / ligne\n→ Champ date → la nouvelle date d'expiration prend effet immédiatement côté client à la prochaine validation", COL.accent],
["Révoquer", "Bouton rouge / ligne\n→ revoked_at = NOW()\nLe client perd ses droits de DL au prochain check, le launcher reste utilisable sur les versions déjà installées", COL.red],
["Libérer machines", "Si un client change de PC, ses anciennes machines occupent un slot\n→ Bouton Libérer machines vide la table license_machines pour cette license", COL.amber],
];
let y = 1.85;
flow.forEach(([title, desc, color]) => {
addAccentCard(s, 0.5, y, W - 1, 0.75, color);
s.addText(title, {
x: 0.75, y: y + 0.1, w: 2, h: 0.3,
fontFace: F_TITLE, fontSize: 14, bold: true, color, margin: 0
});
s.addText(desc, {
x: 2.7, y: y + 0.05, w: W - 3.2, h: 0.7,
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
});
y += 0.85;
});
}
// ===================== SLIDE 9: BACKOFFICE - VERSIONS =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Backoffice — Versions Proserve", "section 3 · catalogue des builds");
addFooter(s, 9);
s.addText("Édite manifest/versions.json depuis le web (sans toucher au JSON à la main).", {
x: 0.5, y: 1.4, w: W - 1, h: 0.3,
fontFace: F_BODY, fontSize: 13, italic: true, color: COL.muted, margin: 0
});
// Workflow numbered
const steps = [
["1", "Ajouter version", "Formulaire haut → Numéro X.Y.Z, date min de license requise, release notes en Markdown."],
["2", "Upload SFTP du ZIP", "Dans builds/proserve-X.Y.Z.zip — taille typique 14 Go par build UE5."],
["3", "Hasher + signer", "Bouton bleu 🔁 Hasher les versions + signer.\nCalcule sizeBytes + sha256, bump latest, signe Ed25519."],
["4", "Édition fine", "Méta (date, minLicenseDate), Notes (Markdown), toggle disponibilité, retirer du manifest."],
];
let y = 1.85;
steps.forEach(([num, title, desc]) => {
addCard(s, 0.5, y, W - 1, 0.75);
s.addShape(pres.shapes.OVAL, {
x: 0.7, y: y + 0.18, w: 0.4, h: 0.4,
fill: { color: COL.accent }, line: { type: "none" }
});
s.addText(num, {
x: 0.7, y: y + 0.18, w: 0.4, h: 0.4,
fontFace: F_TITLE, fontSize: 14, bold: true,
color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
s.addText(title, {
x: 1.3, y: y + 0.1, w: 8, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
});
s.addText(desc, {
x: 1.3, y: y + 0.4, w: 8, h: 0.35,
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
});
y += 0.85;
});
}
// ===================== SLIDE 10: BACKOFFICE - LAUNCHER =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Backoffice — Launcher (auto-update)", "section 3 · MAJ du launcher lui-même");
addFooter(s, 10);
s.addText("Quand tu pousses une nouvelle version de PSLauncher.exe, les launchers déjà déployés se mettent à jour seuls. Cette page pilote ce flux.", {
x: 0.5, y: 1.4, w: W - 1, h: 0.45,
fontFace: F_BODY, fontSize: 12, italic: true, color: COL.muted, margin: 0
});
// Two columns: Status & Workflow
const colY = 2.0;
// Status card
addCard(s, 0.5, colY, 4.3, 2.6);
s.addText("État affiché en haut", {
x: 0.7, y: colY + 0.15, w: 4, h: 0.3,
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
});
addBullets(s, 0.7, colY + 0.55, 4, 2, [
"Version annoncée (ex : v0.8.0)",
"minRequired (plancher de compatibilité)",
"Exe présent / absent dans builds/launcher/",
"Hash calculé / à calculer",
"Manifest signé / non signé",
], { fontSize: 11 });
// Workflow card
addCard(s, 5.2, colY, 4.3, 2.6);
s.addText("Workflow nouvelle version", {
x: 5.4, y: colY + 0.15, w: 4, h: 0.3,
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
});
addBullets(s, 5.4, colY + 0.55, 4, 2, [
"build-launcher.bat (publie l'exe)",
"Renommer en PSLauncher-X.Y.Z.exe",
"Upload SFTP dans builds/launcher/",
"Définir version dans le formulaire",
"Bouton bleu : 🔁 Hasher le launcher + signer",
], { fontSize: 11 });
// Bottom note
addAccentCard(s, 0.5, 4.7, W - 1, 0.55, COL.accent);
s.addText(" Les clients en Program Files passeront un UAC à la mise à jour ; les nouveaux installs (user-mode) n'en demanderont jamais.", {
x: 0.7, y: 4.78, w: W - 1.4, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.text, margin: 0
});
}
// ===================== SLIDE 11: BACKOFFICE - AUDIT =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Backoffice — Audit log", "section 3 · traçabilité");
addFooter(s, 11);
s.addText("Toutes les validations license + émissions d'URL signées sont enregistrées avec horodatage, IP et détail JSON.", {
x: 0.5, y: 1.4, w: W - 1, h: 0.4,
fontFace: F_BODY, fontSize: 12, italic: true, color: COL.muted, margin: 0
});
// Event types table
addCard(s, 0.5, 1.95, W - 1, 3);
s.addText("Événements possibles", {
x: 0.7, y: 2.05, w: W - 1.4, h: 0.3,
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
});
const events = [
["validate_ok", "License validée avec succès", COL.green],
["validate_expired", "Clé connue mais date dépassée", COL.amber],
["validate_revoked", "License révoquée par l'admin", COL.red],
["validate_invalid", "Clé inconnue (faute de frappe)", COL.red],
["machine_limit", "Slot machines max atteint", COL.red],
["download_url_issued", "URL HMAC signée pour DL d'un ZIP", COL.accent],
];
let y = 2.45;
events.forEach(([code, desc, color]) => {
// Pill
addPill(s, 0.7, y, 2.0, 0.28, code, color);
// Description
s.addText(desc, {
x: 2.85, y: y - 0.04, w: 6, h: 0.35,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
y += 0.4;
});
}
// ===================== SLIDE 12: WORKFLOW PROSERVE =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Workflow release — Proserve", "section 4");
addFooter(s, 12);
// Big timeline
const stepW = 1.7, stepGap = 0.18, sy = 1.7;
const steps = [
["1", "Package", "Unreal\n→ ZIP de\nla version"],
["2", "SFTP", "Upload dans\nbuilds/\n(14 Go)"],
["3", "Backoffice", "Onglet Versions\nAjouter X.Y.Z\nRelease notes"],
["4", "Sign", "Bouton bleu\n🔁 Hasher\n+ signer"],
["5", "✓", "Manifest signé\nClients voient\nla MAJ"],
];
steps.forEach((step, i) => {
const x = 0.5 + i * (stepW + stepGap);
const [num, title, desc] = step;
const isLast = i === steps.length - 1;
addCard(s, x, sy, stepW, 2.6, {
fill: isLast ? COL.greenDk : COL.card,
border: isLast ? COL.green : COL.border
});
s.addShape(pres.shapes.OVAL, {
x: x + (stepW - 0.55) / 2, y: sy + 0.2, w: 0.55, h: 0.55,
fill: { color: isLast ? COL.green : COL.accent }, line: { type: "none" }
});
s.addText(num, {
x: x + (stepW - 0.55) / 2, y: sy + 0.2, w: 0.55, h: 0.55,
fontFace: F_TITLE, fontSize: 18, bold: true,
color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
s.addText(title, {
x: x + 0.1, y: sy + 0.85, w: stepW - 0.2, h: 0.35,
fontFace: F_TITLE, fontSize: 13, bold: true,
color: COL.text, align: "center", margin: 0
});
s.addText(desc, {
x: x + 0.1, y: sy + 1.25, w: stepW - 0.2, h: 1.2,
fontFace: F_BODY, fontSize: 11, color: COL.muted, align: "center", margin: 0
});
// Connecting arrow
if (i < steps.length - 1) {
s.addShape(pres.shapes.LINE, {
x: x + stepW, y: sy + 0.475, w: stepGap, h: 0,
line: { color: COL.border, width: 1.5 }
});
}
});
// Notes
addAccentCard(s, 0.5, 4.6, W - 1, 0.65, COL.amber);
s.addText("⚠ Tant que le ZIP n'est pas hashé/signé, les clients ne le verront pas — la signature Ed25519 prouve l'intégrité.", {
x: 0.7, y: 4.7, w: W - 1.4, h: 0.5,
fontFace: F_BODY, fontSize: 11, color: COL.text, margin: 0
});
}
// ===================== SLIDE 13: WORKFLOW LAUNCHER =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Workflow release — Launcher", "section 4");
addFooter(s, 13);
s.addText("Pour pousser une nouvelle version du launcher elle-même (auto-update transparent côté client).", {
x: 0.5, y: 1.4, w: W - 1, h: 0.3,
fontFace: F_BODY, fontSize: 12, italic: true, color: COL.muted, margin: 0
});
// Two columns: scripts / steps
addCard(s, 0.5, 1.85, 4.3, 3.3);
s.addText("Scripts disponibles à la racine", {
x: 0.7, y: 1.95, w: 4, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
});
const scripts = [
["build-launcher.bat", "Publie PSLauncher.exe (~78 Mo)"],
["build-updater.bat", "Publie PSLauncher.Updater.exe"],
["build-all.bat", "Les deux d'un coup"],
["build-installer.bat","Tout + Inno Setup .exe"],
];
let y = 2.35;
scripts.forEach(([cmd, desc]) => {
s.addText(cmd, {
x: 0.7, y, w: 4, h: 0.25,
fontFace: F_CODE, fontSize: 11, color: COL.accent, margin: 0
});
s.addText(desc, {
x: 0.7, y: y + 0.25, w: 4, h: 0.3,
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
});
y += 0.65;
});
// Right: workflow
addCard(s, 5.2, 1.85, 4.3, 3.3);
s.addText("Étapes d'une release", {
x: 5.4, y: 1.95, w: 4, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
});
const wsteps = [
"Bumper <Version> dans PSLauncher.App.csproj",
"build-launcher.bat",
"Renommer PSLauncher.exe → PSLauncher-X.Y.Z.exe",
"SFTP → builds/launcher/",
"Backoffice → Launcher → Définir X.Y.Z",
"Bouton bleu 🔁 Hasher le launcher + signer",
];
addBullets(s, 5.4, 2.4, 4, 2.5, wsteps, { fontSize: 11 });
}
// ===================== SLIDE 14: SECURITY =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Sécurité", "section 6");
addFooter(s, 14);
const blocks = [
["🔏 Ed25519", "Signe le manifest et la réponse de validation license. La clé publique est embarquée dans le launcher, donc même un MITM sur HTTPS ne peut pas falsifier. Sodium PHP natif côté serveur, NSec.Cryptography côté C#.", COL.accent],
["🔗 HMAC-SHA256", "URLs de download présignées par le serveur (path|exp|licId|secret). Apache route via gate.php qui vérifie en constant-time avant de servir le ZIP. Validité 1h, lié à la license.", COL.amber],
["💾 DPAPI local", "La clé license est stockée chiffrée dans %LocalAppData%/PSLauncher/config.json via ProtectedData.Protect (scope CurrentUser). Copier ce fichier sur une autre machine ou un autre user → ne marche pas.", COL.green],
];
let y = 1.55;
blocks.forEach(([title, desc, color]) => {
addAccentCard(s, 0.5, y, W - 1, 1.05, color);
s.addText(title, {
x: 0.75, y: y + 0.15, w: 3, h: 0.35,
fontFace: F_TITLE, fontSize: 15, bold: true, color, margin: 0
});
s.addText(desc, {
x: 0.75, y: y + 0.5, w: W - 1.2, h: 0.55,
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
});
y += 1.15;
});
}
// ===================== SLIDE 15: FILES & LOGS =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Fichiers & logs", "section 7 · localisation");
addFooter(s, 15);
// Server side
s.addText("Côté serveur OVH", {
x: 0.5, y: 1.4, w: 4.3, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.accent, margin: 0
});
addCodeBlock(s, 0.5, 1.75, 4.3, 3.3, [
"www/PS_Launcher/",
"├── api/",
"│ ├── config.php (gitignored)",
"│ ├── index.php",
"│ ├── lib/",
"│ └── routes/",
"├── admin/ (backoffice)",
"├── manifest/versions.json",
"├── releasenotes/X.Y.Z.md",
"├── builds/",
"│ ├── proserve-X.Y.Z.zip",
"│ └── launcher/",
"│ └── PSLauncher-X.Y.Z.exe",
"├── tools/sign-manifest.php",
"└── migrations/001_init.sql",
]);
// Client side
s.addText("Côté client Windows", {
x: 5.2, y: 1.4, w: 4.3, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.accent, margin: 0
});
addCodeBlock(s, 5.2, 1.75, 4.3, 3.3, [
"%LocalAppData%/PSLauncher/",
"├── config.json",
"│ (license chiffrée DPAPI)",
"├── logs/",
"│ ├── app-YYYYMMDD.log",
"│ └── updater.log",
"├── downloads/",
"│ ├── *.partial",
"│ └── *.state.json",
"└── selfupdate/",
"",
"C:\\ASTERION_VR/",
" (default installRoot)",
" ├── Proserve v1.4.5/",
" ├── Proserve v1.4.6/",
" └── ...",
]);
}
// ===================== SLIDE 16: TROUBLESHOOTING =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Troubleshooting", "section 7 · pannes courantes");
addFooter(s, 16);
const issues = [
["⛔", "500 sur l'API", "Vérifier api/config.php existe + valeurs renseignées (debug endpoint /api/debug). Logs PHP : Manager OVH → Logs.", COL.red],
["⚠", "Hash incorrect côté client", "Le ZIP a changé sans resync. Backoffice → Versions → bouton bleu 🔁 pour resigner.", COL.amber],
["⚠", "License invalide après désactivation", "Cache local toujours valide 7 jours. Désactiver/réactiver depuis Settings → ⚙ ou supprimer config.json.", COL.amber],
["⛔", "Updater ne relance pas", "Vérifier %LocalAppData%/PSLauncher/logs/updater.log. Si UAC refusé en Program Files : copier manuellement le binaire.", COL.red],
["✓", "Tout marche mais pas d'auto-update", "Section 'launcher' absente du manifest. Onglet Launcher → Définir + 🔁.", COL.green],
];
let y = 1.55;
issues.forEach(([icon, title, desc, color]) => {
addCard(s, 0.5, y, W - 1, 0.65);
s.addText(icon, {
x: 0.7, y: y + 0.1, w: 0.5, h: 0.45,
fontFace: F_TITLE, fontSize: 18, bold: true, color, align: "center", margin: 0
});
s.addText(title, {
x: 1.2, y: y + 0.07, w: 3, h: 0.3,
fontFace: F_TITLE, fontSize: 12, bold: true, color: COL.text, margin: 0
});
s.addText(desc, {
x: 1.2, y: y + 0.32, w: W - 1.7, h: 0.32,
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
});
y += 0.72;
});
}
// ===================== SLIDE 17: CLOSING =====================
{
const s = pres.addSlide();
fillBg(s);
// Diagonal band
s.addShape(pres.shapes.RECTANGLE, {
x: 0, y: 1.3, w: W, h: 1.5, fill: { color: COL.accentDk }, line: { type: "none" }
});
s.addShape(pres.shapes.RECTANGLE, {
x: 0, y: 1.3, w: W, h: 0.05, fill: { color: COL.accent }, line: { type: "none" }
});
s.addText("Fin de la documentation interne", {
x: 0.5, y: 1.55, w: W - 1, h: 0.6,
fontFace: F_TITLE, fontSize: 30, bold: true, color: COL.text, margin: 0
});
s.addText("Pour le guide utilisateur destiné aux clients : voir document séparé.", {
x: 0.5, y: 2.1, w: W - 1, h: 0.4,
fontFace: F_BODY, fontSize: 14, italic: true, color: COL.muted, margin: 0
});
// Three quick-reference cards
const ix = 0.5, iy = 3.4, iw = 3.0, gap = 0.15;
const refs = [
["📂 Repo", "C:\\ASTERION\\GIT\\PS_Launcher", COL.accent],
["🌐 Backoffice", "asterionvr.com/PS_Launcher/admin/", COL.green],
["📋 Logs", "%LocalAppData%\\PSLauncher\\logs\\", COL.amber],
];
refs.forEach(([title, path, color], i) => {
const x = ix + i * (iw + gap);
addAccentCard(s, x, iy, iw, 1.1, color);
s.addText(title, {
x: x + 0.2, y: iy + 0.15, w: iw - 0.4, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color, margin: 0
});
s.addText(path, {
x: x + 0.2, y: iy + 0.5, w: iw - 0.4, h: 0.55,
fontFace: F_CODE, fontSize: 10, color: COL.text, margin: 0
});
});
// Footer brand
s.addShape(pres.shapes.RECTANGLE, {
x: 0, y: H - 0.5, w: W, h: 0.5, fill: { color: COL.card }, line: { type: "none" }
});
s.addText("© 2026 ASTERION VR — All rights reserved", {
x: 0.5, y: H - 0.45, w: W - 1, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.muted, align: "center", margin: 0
});
}
pres.writeFile({ fileName: "C:\\ASTERION\\GIT\\PS_Launcher\\docs\\PS_Launcher-Documentation-Interne.pptx" })
.then(f => console.log("Generated:", f));

793
docs/_build_user.js Normal file
View File

@@ -0,0 +1,793 @@
// Generates PS_Launcher-Guide-Utilisateur.pptx
const pptxgen = require("pptxgenjs");
const COL = {
bg: "000000",
card: "161B23",
elev: "1E2632",
border: "2A2F3A",
text: "F2F2F2",
muted: "A0A0A8",
accent: "3B82F6",
accentDk: "1F3A66",
green: "16A34A",
greenDk: "0E2A1B",
amber: "F59E0B",
amberDk: "3A2A0E",
red: "EF4444",
redDk: "2A1414",
codeBg: "0A0E14",
};
const F_TITLE = "Calibri";
const F_BODY = "Calibri";
const F_CODE = "Consolas";
const pres = new pptxgen();
pres.layout = "LAYOUT_16x9";
pres.author = "ASTERION VR";
pres.title = "PROSERVE Launcher — Guide Utilisateur";
pres.company = "ASTERION VR";
const W = 10, H = 5.625;
// ---- helpers (same as internal but kept local to avoid module fuss) ----
function fillBg(slide) { slide.background = { color: COL.bg }; }
function addHeader(slide, title, kicker) {
slide.addShape(pres.shapes.RECTANGLE, {
x: 0, y: 0, w: W, h: 0.06, fill: { color: COL.green }, line: { type: "none" }
});
if (kicker) {
slide.addText(kicker.toUpperCase(), {
x: 0.5, y: 0.25, w: W - 1, h: 0.3,
fontFace: F_BODY, fontSize: 11, bold: true,
color: COL.green, charSpacing: 4, margin: 0
});
}
slide.addText(title, {
x: 0.5, y: kicker ? 0.55 : 0.35, w: W - 1, h: 0.7,
fontFace: F_TITLE, fontSize: 32, bold: true,
color: COL.text, margin: 0
});
slide.addShape(pres.shapes.LINE, {
x: 0.5, y: kicker ? 1.3 : 1.15, w: 1.2, h: 0,
line: { color: COL.green, width: 2 }
});
}
function addFooter(slide, page) {
slide.addText("PROSERVE Launcher • Guide Utilisateur • ASTERION VR", {
x: 0.5, y: H - 0.35, w: W - 2, h: 0.25,
fontFace: F_BODY, fontSize: 9, color: COL.muted, margin: 0
});
if (page) {
slide.addText(String(page), {
x: W - 1, y: H - 0.35, w: 0.5, h: 0.25,
fontFace: F_BODY, fontSize: 9, color: COL.muted, align: "right", margin: 0
});
}
}
function addCard(slide, x, y, w, h, options = {}) {
slide.addShape(pres.shapes.RECTANGLE, {
x, y, w, h,
fill: { color: options.fill || COL.card },
line: { color: options.border || COL.border, width: 1 },
});
}
function addAccentCard(slide, x, y, w, h, accentColor) {
addCard(slide, x, y, w, h);
slide.addShape(pres.shapes.RECTANGLE, {
x, y, w: 0.08, h,
fill: { color: accentColor }, line: { type: "none" }
});
}
function addCodeBlock(slide, x, y, w, h, lines) {
slide.addShape(pres.shapes.RECTANGLE, {
x, y, w, h, fill: { color: COL.codeBg }, line: { color: COL.border, width: 1 }
});
const text = (Array.isArray(lines) ? lines : [lines]).map((line, i, arr) => ({
text: line,
options: { breakLine: i < arr.length - 1 }
}));
slide.addText(text, {
x: x + 0.15, y: y + 0.1, w: w - 0.3, h: h - 0.2,
fontFace: F_CODE, fontSize: 11, color: COL.text, margin: 0,
valign: "top"
});
}
function addBullets(slide, x, y, w, h, items, opts = {}) {
const fontSize = opts.fontSize || 14;
const color = opts.color || COL.text;
const arr = items.map((it, i) => ({
text: it,
options: { bullet: { code: "25A0" }, breakLine: i < items.length - 1, color }
}));
slide.addText(arr, {
x, y, w, h, fontFace: F_BODY, fontSize, color, margin: 0,
paraSpaceAfter: 6
});
}
// ===================== SLIDE 1: COVER =====================
{
const s = pres.addSlide();
fillBg(s);
// Big band on left
s.addShape(pres.shapes.RECTANGLE, {
x: 0, y: 0, w: 3.5, h: H, fill: { color: COL.greenDk }, line: { type: "none" }
});
s.addShape(pres.shapes.RECTANGLE, {
x: 3.45, y: 0, w: 0.05, h: H, fill: { color: COL.green }, line: { type: "none" }
});
// Brand
s.addText("ASTERION VR", {
x: 0.4, y: 0.5, w: 3, h: 0.4,
fontFace: F_BODY, fontSize: 13, bold: true,
color: COL.green, charSpacing: 8, margin: 0
});
// Big right title
s.addText("PROSERVE", {
x: 3.9, y: 1.3, w: 6, h: 1,
fontFace: F_TITLE, fontSize: 60, bold: true,
color: COL.text, margin: 0
});
s.addText("Launcher", {
x: 3.9, y: 2.1, w: 6, h: 0.7,
fontFace: F_TITLE, fontSize: 40, bold: true,
color: COL.muted, margin: 0
});
s.addText("Guide utilisateur", {
x: 3.9, y: 3.0, w: 6, h: 0.5,
fontFace: F_TITLE, fontSize: 22,
color: COL.green, margin: 0
});
s.addText("Installation, activation et utilisation", {
x: 3.9, y: 3.5, w: 6, h: 0.4,
fontFace: F_BODY, fontSize: 14, italic: true,
color: COL.muted, margin: 0
});
// Left band content
s.addText("v0.8", {
x: 0.4, y: 2, w: 3, h: 1,
fontFace: F_TITLE, fontSize: 56, bold: true,
color: COL.text, align: "center", margin: 0
});
s.addText("Mai 2026", {
x: 0.4, y: 2.95, w: 3, h: 0.3,
fontFace: F_BODY, fontSize: 13,
color: COL.muted, align: "center", margin: 0
});
// Bottom strip
s.addShape(pres.shapes.RECTANGLE, {
x: 0, y: H - 0.5, w: W, h: 0.5, fill: { color: COL.card }, line: { type: "none" }
});
s.addText("© 2026 ASTERION VR — Tous droits réservés", {
x: 0.5, y: H - 0.45, w: W - 1, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
}
// ===================== SLIDE 2: WHAT IS IT =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Qu'est-ce que PROSERVE Launcher ?", "introduction");
addFooter(s, 2);
s.addText("Une application Windows qui te permet de :", {
x: 0.5, y: 1.4, w: W - 1, h: 0.4,
fontFace: F_BODY, fontSize: 16, color: COL.text, margin: 0
});
const features = [
["📦", "Installer", "Télécharge et déploie chaque nouvelle version de Proserve sur ta machine en quelques clics."],
["▶", "Lancer", "Démarre la version de ton choix. Plusieurs versions peuvent cohabiter sur le même PC."],
["🔄", "Mettre à jour", "Détecte automatiquement les nouvelles versions disponibles côté serveur ASTERION et propose la mise à jour."],
["🔑", "License", "Vérifie ta license en ligne (ou via cache local 7 jours en cas de coupure réseau)."],
];
let y = 2.0;
features.forEach(([icon, title, desc]) => {
addCard(s, 0.5, y, W - 1, 0.7);
s.addText(icon, {
x: 0.7, y: y + 0.13, w: 0.6, h: 0.45,
fontFace: F_TITLE, fontSize: 20, color: COL.green, align: "center", margin: 0
});
s.addText(title, {
x: 1.4, y: y + 0.07, w: 2, h: 0.3,
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
});
s.addText(desc, {
x: 1.4, y: y + 0.34, w: W - 1.9, h: 0.35,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
y += 0.78;
});
}
// ===================== SLIDE 3: PREREQUISITES =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Pré-requis", "avant de commencer");
addFooter(s, 3);
const prereqs = [
["💻", "Windows 10 ou 11", "Édition 64-bit. Pas de version macOS / Linux pour l'instant."],
["🌐", "Connexion internet", "Requise pour activer la license et télécharger les versions. Le launcher fonctionne hors-ligne pendant 7 jours après la dernière validation."],
["💾", "Espace disque", "Prévoir ~14 Go par version installée + ~14 Go de cache temporaire pendant le téléchargement."],
["🔑", "Une clé license", "Format PRSRV-XXXX-XXXX-XXXX-XXXX, fournie par ASTERION VR."],
];
let y = 1.5;
prereqs.forEach(([icon, title, desc]) => {
addAccentCard(s, 0.5, y, W - 1, 0.85, COL.green);
s.addText(icon, {
x: 0.75, y: y + 0.18, w: 0.7, h: 0.5,
fontFace: F_TITLE, fontSize: 22, color: COL.green, align: "center", margin: 0
});
s.addText(title, {
x: 1.5, y: y + 0.12, w: 6, h: 0.3,
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
});
s.addText(desc, {
x: 1.5, y: y + 0.42, w: W - 2, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
y += 0.95;
});
}
// ===================== SLIDE 4: INSTALLATION =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Installation", "première fois");
addFooter(s, 4);
const steps = [
["1", "Télécharge le setup", "Tu reçois un fichier nommé PSLauncher-Setup-X.Y.Z.exe (~80 Mo) — par email, lien WeTransfer, ou via le site ASTERION."],
["2", "Double-clique pour lancer", "Windows peut afficher un avertissement SmartScreen « Éditeur inconnu ».\nClique « Informations complémentaires » → « Exécuter quand même »."],
["3", "Suis l'assistant", "Choisis la langue, valide les chemins par défaut. Le launcher s'installe dans ton profil utilisateur — pas besoin de droits administrateur."],
["4", "Lance PROSERVE Launcher", "Un raccourci apparaît sur le bureau et dans le menu Démarrer."],
];
let y = 1.5;
steps.forEach(([num, title, desc]) => {
addCard(s, 0.5, y, W - 1, 0.85);
s.addShape(pres.shapes.OVAL, {
x: 0.7, y: y + 0.2, w: 0.45, h: 0.45,
fill: { color: COL.green }, line: { type: "none" }
});
s.addText(num, {
x: 0.7, y: y + 0.2, w: 0.45, h: 0.45,
fontFace: F_TITLE, fontSize: 16, bold: true,
color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
s.addText(title, {
x: 1.4, y: y + 0.1, w: W - 2, h: 0.3,
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
});
s.addText(desc, {
x: 1.4, y: y + 0.4, w: W - 2, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
y += 0.95;
});
}
// ===================== SLIDE 5: ACTIVATE LICENSE =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Activer ta license", "premier démarrage");
addFooter(s, 5);
s.addText("Au premier lancement, le badge en haut au centre de la fenêtre indique « 🔒 Aucune license » en rouge.", {
x: 0.5, y: 1.4, w: W - 1, h: 0.45,
fontFace: F_BODY, fontSize: 13, color: COL.text, margin: 0
});
// Badge mockup
addAccentCard(s, 0.5, 1.95, 4.5, 1.6, COL.red);
s.addText("🔒 Aucune license", {
x: 0.7, y: 2.05, w: 4, h: 0.4,
fontFace: F_TITLE, fontSize: 16, bold: true, color: COL.red, margin: 0
});
s.addText("Visible dans la barre du haut au centre", {
x: 0.7, y: 2.4, w: 4, h: 0.3,
fontFace: F_BODY, fontSize: 11, italic: true, color: COL.muted, margin: 0
});
s.addText("👉 Clique dessus !", {
x: 0.7, y: 2.85, w: 4, h: 0.5,
fontFace: F_TITLE, fontSize: 16, bold: true, color: COL.green, margin: 0
});
// Then
addAccentCard(s, 5.2, 1.95, 4.3, 1.6, COL.green);
s.addText("Dialog d'activation", {
x: 5.4, y: 2.05, w: 4, h: 0.3,
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
});
addBullets(s, 5.4, 2.4, 4, 1.1, [
"Colle ta clé PRSRV-…",
"Clique « Activer »",
"Le badge passe en vert ✓",
], { fontSize: 11 });
// Result
addCard(s, 0.5, 3.85, W - 1, 1.0);
s.addText("Une fois activée :", {
x: 0.7, y: 3.95, w: 4, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.green, margin: 0
});
s.addText("La barre indique le nom du client + la date de validité. Tu peux maintenant télécharger les versions de Proserve auxquelles ta license te donne accès.", {
x: 0.7, y: 4.25, w: W - 1.4, h: 0.6,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
}
// ===================== SLIDE 6: LIBRARY OVERVIEW =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "La fenêtre principale", "ta bibliothèque");
addFooter(s, 6);
// Hero card simulated
addAccentCard(s, 0.5, 1.4, W - 1, 1.2, COL.green);
s.addText("VERSION COURANTE", {
x: 0.75, y: 1.5, w: 4, h: 0.3,
fontFace: F_BODY, fontSize: 10, bold: true, color: COL.muted, charSpacing: 4, margin: 0
});
s.addText("Proserve v1.4.7", {
x: 0.75, y: 1.78, w: 5, h: 0.5,
fontFace: F_TITLE, fontSize: 24, bold: true, color: COL.text, margin: 0
});
s.addText("● Installée • Sortie le 29/04/2026 • 14 Go", {
x: 0.75, y: 2.25, w: 5, h: 0.3,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
// Big launcher button mockup
s.addShape(pres.shapes.ROUNDED_RECTANGLE, {
x: 7.4, y: 1.7, w: 1.8, h: 0.6,
fill: { color: COL.green }, line: { type: "none" }, rectRadius: 0.05
});
s.addText("▶ LANCER", {
x: 7.4, y: 1.7, w: 1.8, h: 0.6,
fontFace: F_TITLE, fontSize: 16, bold: true,
color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
// Section header
s.addText("AUTRES VERSIONS", {
x: 0.5, y: 2.85, w: 3, h: 0.3,
fontFace: F_BODY, fontSize: 10, bold: true, color: COL.muted, charSpacing: 4, margin: 0
});
// Other rows
const rowY1 = 3.2, rowY2 = 4.0;
addAccentCard(s, 0.5, rowY1, W - 1, 0.7, COL.green);
s.addText("v1.4.6 ● Installée", {
x: 0.75, y: rowY1 + 0.1, w: 5, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
});
s.addText("14 Go • 15/04/2026", {
x: 0.75, y: rowY1 + 0.4, w: 5, h: 0.3,
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
});
s.addShape(pres.shapes.ROUNDED_RECTANGLE, {
x: 8.3, y: rowY1 + 0.18, w: 1.05, h: 0.4,
fill: { color: COL.green }, line: { type: "none" }, rectRadius: 0.05
});
s.addText("▶ Lancer", {
x: 8.3, y: rowY1 + 0.18, w: 1.05, h: 0.4,
fontFace: F_BODY, fontSize: 11, bold: true, color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
addAccentCard(s, 0.5, rowY2, W - 1, 0.7, COL.accent);
s.addText("v1.4.5 ○ Disponible", {
x: 0.75, y: rowY2 + 0.1, w: 5, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
});
s.addText("448 Mo • 15/04/2026", {
x: 0.75, y: rowY2 + 0.4, w: 5, h: 0.3,
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
});
s.addShape(pres.shapes.ROUNDED_RECTANGLE, {
x: 8.1, y: rowY2 + 0.18, w: 1.25, h: 0.4,
fill: { color: COL.accent }, line: { type: "none" }, rectRadius: 0.05
});
s.addText("⬇ Installer", {
x: 8.1, y: rowY2 + 0.18, w: 1.25, h: 0.4,
fontFace: F_BODY, fontSize: 11, bold: true, color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
}
// ===================== SLIDE 7: LAUNCH A VERSION =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Lancer une version", "▶ jouer");
addFooter(s, 7);
s.addText("Deux possibilités :", {
x: 0.5, y: 1.4, w: W - 1, h: 0.3,
fontFace: F_BODY, fontSize: 14, color: COL.text, margin: 0
});
addAccentCard(s, 0.5, 1.85, 4.5, 2.5, COL.green);
s.addText("LA PLUS RÉCENTE", {
x: 0.7, y: 1.95, w: 4, h: 0.3,
fontFace: F_BODY, fontSize: 11, bold: true, color: COL.green, charSpacing: 3, margin: 0
});
s.addText("Le gros bouton vert dans la carte « Version courante » lance la version la plus récente que tu as installée.", {
x: 0.7, y: 2.3, w: 4, h: 1.2,
fontFace: F_BODY, fontSize: 12, color: COL.text, margin: 0
});
s.addShape(pres.shapes.ROUNDED_RECTANGLE, {
x: 0.7, y: 3.55, w: 4, h: 0.6,
fill: { color: COL.green }, line: { type: "none" }, rectRadius: 0.05
});
s.addText("▶ LANCER", {
x: 0.7, y: 3.55, w: 4, h: 0.6,
fontFace: F_TITLE, fontSize: 16, bold: true,
color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
addAccentCard(s, 5.2, 1.85, 4.3, 2.5, COL.green);
s.addText("UNE VERSION ANCIENNE", {
x: 5.4, y: 1.95, w: 4, h: 0.3,
fontFace: F_BODY, fontSize: 11, bold: true, color: COL.green, charSpacing: 3, margin: 0
});
s.addText("Dans la section « Autres versions », chaque ligne installée a son propre bouton ▶ Lancer pour démarrer cette version précisément.", {
x: 5.4, y: 2.3, w: 4, h: 1.5,
fontFace: F_BODY, fontSize: 12, color: COL.text, margin: 0
});
// Behavior
addCard(s, 0.5, 4.55, W - 1, 0.7);
s.addText("", {
x: 0.7, y: 4.65, w: 0.5, h: 0.5,
fontFace: F_TITLE, fontSize: 18, bold: true, color: COL.accent, align: "center", margin: 0
});
s.addText("La fenêtre du launcher se réduit automatiquement dans la barre des tâches pour laisser la place à Proserve.", {
x: 1.2, y: 4.7, w: W - 1.7, h: 0.5,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
}
// ===================== SLIDE 8: INSTALL NEW VERSION =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Installer une nouvelle version", "📦 télécharger");
addFooter(s, 8);
const steps = [
["1", "Vérifie les MAJ", "Bouton « 🔄 Vérifier les MAJ » en bas à gauche.\nLe launcher contacte le serveur et liste les nouvelles versions."],
["2", "Repère la ligne bleue", "Une ligne « ○ Disponible » apparaît avec un bouton bleu « ⬇ Installer X.Y.Z »."],
["3", "Clique « ⬇ Installer »", "Une fenêtre s'ouvre avec les notes de version (Markdown). Lis et clique « Télécharger » pour confirmer."],
["4", "Patience pendant le DL", "La barre de progression en bas montre la vitesse, l'avancement, l'ETA. Tu peux mettre en pause ou annuler."],
["5", "Installation auto", "Une fois le ZIP vérifié (SHA-256), il est extrait dans le dossier d'install à côté des autres versions."],
];
let y = 1.45;
steps.forEach(([num, title, desc]) => {
addCard(s, 0.5, y, W - 1, 0.7);
s.addShape(pres.shapes.OVAL, {
x: 0.7, y: y + 0.13, w: 0.4, h: 0.4,
fill: { color: COL.accent }, line: { type: "none" }
});
s.addText(num, {
x: 0.7, y: y + 0.13, w: 0.4, h: 0.4,
fontFace: F_TITLE, fontSize: 14, bold: true,
color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
s.addText(title, {
x: 1.3, y: y + 0.07, w: 8, h: 0.27,
fontFace: F_TITLE, fontSize: 12, bold: true, color: COL.text, margin: 0
});
s.addText(desc, {
x: 1.3, y: y + 0.32, w: 8, h: 0.4,
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
});
y += 0.78;
});
}
// ===================== SLIDE 9: PROGRESS / RESUME =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Suivre un téléchargement", "barre du bas");
addFooter(s, 9);
// Mock footer
addCard(s, 0.5, 1.5, W - 1, 0.7);
s.addText("⬇ v1.4.7 : 4,2 / 14,0 Go • 38 Mo/s • ETA 4 min", {
x: 0.75, y: 1.6, w: 7.5, h: 0.3,
fontFace: F_CODE, fontSize: 12, color: COL.muted, margin: 0
});
// Progress bar
s.addShape(pres.shapes.RECTANGLE, {
x: 0.75, y: 1.95, w: 7.5, h: 0.1, fill: { color: "2A2A30" }, line: { type: "none" }
});
s.addShape(pres.shapes.RECTANGLE, {
x: 0.75, y: 1.95, w: 2.25, h: 0.1, fill: { color: COL.accent }, line: { type: "none" }
});
// Cancel button
s.addShape(pres.shapes.ROUNDED_RECTANGLE, {
x: 8.5, y: 1.62, w: 0.85, h: 0.4,
fill: { color: "3A3A40" }, line: { type: "none" }, rectRadius: 0.05
});
s.addText("Annuler", {
x: 8.5, y: 1.62, w: 0.85, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.text, align: "center", valign: "middle", margin: 0
});
// Features
s.addText("Ce que tu peux faire pendant le DL", {
x: 0.5, y: 2.5, w: W - 1, h: 0.3,
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.text, margin: 0
});
addBullets(s, 0.5, 2.85, W - 1, 1.1, [
"Annuler à tout moment (le ZIP partiel est conservé pour reprendre plus tard)",
"Fermer le launcher : la prochaine ouverture proposera de reprendre",
"Couper le réseau : le launcher attend (retry exponentiel) puis reprend automatiquement",
], { fontSize: 12 });
// Resume callout
addAccentCard(s, 0.5, 4.15, W - 1, 0.95, COL.amber);
s.addText("↻ Reprendre", {
x: 0.75, y: 4.25, w: 2, h: 0.35,
fontFace: F_TITLE, fontSize: 14, bold: true, color: COL.amber, margin: 0
});
s.addText("Un DL interrompu apparaît avec un bouton « ↻ Reprendre (X%) » à la place de « Installer ». Le téléchargement reprend là où il s'était arrêté — pas besoin de tout recommencer.", {
x: 0.75, y: 4.55, w: W - 1.5, h: 0.6,
fontFace: F_BODY, fontSize: 11, color: COL.muted, margin: 0
});
}
// ===================== SLIDE 10: VERSION MENU =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Menu d'une version", "bouton ⋯");
addFooter(s, 10);
s.addText("Le bouton « ⋯ » à droite de chaque ligne ouvre un menu contextuel.", {
x: 0.5, y: 1.4, w: W - 1, h: 0.4,
fontFace: F_BODY, fontSize: 13, color: COL.muted, italic: true, margin: 0
});
// Menu mockup
addCard(s, 0.5, 1.95, 4.5, 2.4);
s.addText("📜 Voir les release notes", {
x: 0.75, y: 2.1, w: 4, h: 0.4,
fontFace: F_BODY, fontSize: 13, color: COL.text, margin: 0
});
s.addShape(pres.shapes.LINE, {
x: 0.75, y: 2.5, w: 4, h: 0,
line: { color: COL.border, width: 0.5 }
});
s.addText("📁 Ouvrir le dossier", {
x: 0.75, y: 2.6, w: 4, h: 0.4,
fontFace: F_BODY, fontSize: 13, color: COL.text, margin: 0
});
s.addShape(pres.shapes.LINE, {
x: 0.75, y: 3.0, w: 4, h: 0,
line: { color: COL.border, width: 0.5 }
});
s.addText("🗑 Supprimer cette version…", {
x: 0.75, y: 3.1, w: 4, h: 0.4,
fontFace: F_BODY, fontSize: 13, color: COL.text, margin: 0
});
// Description
addBullets(s, 5.3, 2.1, 4.2, 2.4, [
"Release notes : disponibles aussi sur les versions non installées",
"Ouvrir le dossier : utile pour vérifier l'installation",
"Supprimer : libère l'espace disque (≈ 14 Go) avec confirmation. Garde tes anciennes versions tant que tu veux pouvoir y revenir.",
], { fontSize: 11 });
// Tip
addAccentCard(s, 0.5, 4.55, W - 1, 0.65, COL.green);
s.addText("✓ Aucune suppression automatique : c'est toujours toi qui décides quand effacer une vieille version.", {
x: 0.75, y: 4.65, w: W - 1.5, h: 0.5,
fontFace: F_BODY, fontSize: 11, color: COL.text, margin: 0
});
}
// ===================== SLIDE 11: SETTINGS =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Paramètres ⚙", "bouton en haut à droite");
addFooter(s, 11);
const sections = [
["🔑", "License", "Statut, propriétaire, date d'expiration. Identifiant machine anonyme (à communiquer à ASTERION en cas de souci de slot machine)."],
["🌐", "Serveur", "URL de l'API ASTERION. À ne changer que si tu y es invité par le support."],
["📁", "Installation", "Dossier où sont posées les versions de Proserve. Par défaut C:\\ASTERION_VR. Bouton « Parcourir » pour changer."],
["💾", "Cache", "Taille du cache de téléchargements. Bouton « Vider » pour libérer de l'espace si besoin."],
["📋", "Logs", "Dossier des logs (.log) — utile en cas de souci, à transmettre au support."],
];
let y = 1.4;
sections.forEach(([icon, title, desc]) => {
addCard(s, 0.5, y, W - 1, 0.7);
s.addText(icon, {
x: 0.7, y: y + 0.12, w: 0.5, h: 0.45,
fontFace: F_TITLE, fontSize: 18, color: COL.green, align: "center", margin: 0
});
s.addText(title, {
x: 1.25, y: y + 0.07, w: 2, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
});
s.addText(desc, {
x: 1.25, y: y + 0.33, w: W - 1.7, h: 0.4,
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
});
y += 0.78;
});
}
// ===================== SLIDE 12: AUTO-UPDATE =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "Mises à jour du launcher", "🔄 automatiques");
addFooter(s, 12);
s.addText("Le launcher se met à jour tout seul quand ASTERION pousse une nouvelle version.", {
x: 0.5, y: 1.4, w: W - 1, h: 0.4,
fontFace: F_BODY, fontSize: 13, color: COL.text, margin: 0
});
const flow = [
["1", "Détection", "Au démarrage et à chaque clic « Vérifier les MAJ », le launcher contacte le serveur."],
["2", "Notification", "Si une nouvelle version du launcher existe, une popup t'informe et propose de mettre à jour."],
["3", "Téléchargement", "Quelques secondes (le launcher fait ~80 Mo)."],
["4", "Redémarrage automatique", "L'application se ferme, est remplacée, puis se relance avec la nouvelle version."],
];
let y = 1.95;
flow.forEach(([num, title, desc]) => {
addCard(s, 0.5, y, W - 1, 0.65);
s.addShape(pres.shapes.OVAL, {
x: 0.7, y: y + 0.12, w: 0.4, h: 0.4,
fill: { color: COL.green }, line: { type: "none" }
});
s.addText(num, {
x: 0.7, y: y + 0.12, w: 0.4, h: 0.4,
fontFace: F_TITLE, fontSize: 14, bold: true,
color: "FFFFFF", align: "center", valign: "middle", margin: 0
});
s.addText(title, {
x: 1.3, y: y + 0.07, w: 8, h: 0.27,
fontFace: F_TITLE, fontSize: 12, bold: true, color: COL.text, margin: 0
});
s.addText(desc, {
x: 1.3, y: y + 0.32, w: 8, h: 0.32,
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
});
y += 0.73;
});
addAccentCard(s, 0.5, 4.95, W - 1, 0.5, COL.green);
s.addText("✓ Tu peux toujours refuser et reporter — clique « Plus tard ».", {
x: 0.75, y: 5.0, w: W - 1.5, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.text, margin: 0
});
}
// ===================== SLIDE 13: FAQ =====================
{
const s = pres.addSlide();
fillBg(s);
addHeader(s, "FAQ", "questions fréquentes");
addFooter(s, 13);
const qs = [
["❓", "Ma license est expirée. Que se passe-t-il ?",
"Tu peux toujours utiliser les versions déjà installées. Seul le téléchargement de nouvelles versions est bloqué jusqu'à renouvellement.",
COL.amber],
["❓", "Pas de connexion internet — le launcher marche-t-il ?",
"Oui, pendant 7 jours après la dernière validation en ligne. Au-delà, la license est considérée comme à revérifier.",
COL.accent],
["❓", "Comment changer de PC ?",
"Désactive ta license sur l'ancien (Settings → bouton Désactiver), puis active-la sur le nouveau avec la même clé.",
COL.green],
["❓", "Le téléchargement échoue avec « SHA-256 invalide » ?",
"Réseau bruité ou ZIP modifié côté serveur entre-temps. Le partial est jeté ; le DL recommence proprement au prochain clic.",
COL.red],
];
let y = 1.5;
qs.forEach(([icon, q, a, color]) => {
addAccentCard(s, 0.5, y, W - 1, 0.85, color);
s.addText(icon, {
x: 0.7, y: y + 0.18, w: 0.4, h: 0.45,
fontFace: F_TITLE, fontSize: 16, color, align: "center", margin: 0
});
s.addText(q, {
x: 1.2, y: y + 0.1, w: W - 1.7, h: 0.3,
fontFace: F_TITLE, fontSize: 12, bold: true, color: COL.text, margin: 0
});
s.addText(a, {
x: 1.2, y: y + 0.4, w: W - 1.7, h: 0.45,
fontFace: F_BODY, fontSize: 10, color: COL.muted, margin: 0
});
y += 0.95;
});
}
// ===================== SLIDE 14: SUPPORT =====================
{
const s = pres.addSlide();
fillBg(s);
// Big band
s.addShape(pres.shapes.RECTANGLE, {
x: 0, y: 1.3, w: W, h: 1.3, fill: { color: COL.greenDk }, line: { type: "none" }
});
s.addShape(pres.shapes.RECTANGLE, {
x: 0, y: 1.3, w: W, h: 0.05, fill: { color: COL.green }, line: { type: "none" }
});
s.addText("Besoin d'aide ?", {
x: 0.5, y: 1.55, w: W - 1, h: 0.6,
fontFace: F_TITLE, fontSize: 32, bold: true, color: COL.text, margin: 0
});
s.addText("Contacte ASTERION VR avec ces infos pour un support efficace :", {
x: 0.5, y: 2.1, w: W - 1, h: 0.4,
fontFace: F_BODY, fontSize: 14, italic: true, color: COL.muted, margin: 0
});
// Three cards
const ix = 0.5, iy = 3.05, iw = 3.0, gap = 0.15;
const refs = [
["🔑", "Ta clé license", "PRSRV-XXXX-XXXX-XXXX-XXXX", COL.green],
["💻", "Ton ID machine", "Settings → License\nbouton 📋 Copier", COL.accent],
["📋", "Tes logs", "Settings → Logs\nbouton 📁 Ouvrir", COL.amber],
];
refs.forEach(([icon, title, body, color], i) => {
const x = ix + i * (iw + gap);
addAccentCard(s, x, iy, iw, 1.7, color);
s.addText(icon, {
x: x + 0.2, y: iy + 0.15, w: iw - 0.4, h: 0.45,
fontFace: F_TITLE, fontSize: 22, color, margin: 0
});
s.addText(title, {
x: x + 0.2, y: iy + 0.65, w: iw - 0.4, h: 0.3,
fontFace: F_TITLE, fontSize: 13, bold: true, color: COL.text, margin: 0
});
s.addText(body, {
x: x + 0.2, y: iy + 0.95, w: iw - 0.4, h: 0.7,
fontFace: F_CODE, fontSize: 10, color: COL.muted, margin: 0
});
});
// Footer brand
s.addShape(pres.shapes.RECTANGLE, {
x: 0, y: H - 0.5, w: W, h: 0.5, fill: { color: COL.card }, line: { type: "none" }
});
s.addText("© 2026 ASTERION VR — Tous droits réservés", {
x: 0.5, y: H - 0.45, w: W - 1, h: 0.4,
fontFace: F_BODY, fontSize: 11, color: COL.muted, align: "center", margin: 0
});
}
pres.writeFile({ fileName: "C:\\ASTERION\\GIT\\PS_Launcher\\docs\\PS_Launcher-Guide-Utilisateur.pptx" })
.then(f => console.log("Generated:", f));

View File

@@ -4,18 +4,18 @@
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" PSLauncher.iss
;
; Avant compile :
; 1. Faire `dotnet publish src/PSLauncher.App -c Release` (génère PSLauncher.exe ~77 Mo)
; 2. Faire `dotnet publish src/PSLauncher.Updater -c Release` (génère PSLauncher.Updater.exe ~10 Mo)
; 1. Faire `dotnet publish src/PSLauncher.App -c Release` (génère PS_Launcher.exe ~77 Mo)
; 2. Faire `dotnet publish src/PSLauncher.Updater -c Release` (génère PS_Launcher.Updater.exe ~10 Mo)
;
; Le script picore directement les binaires dans bin/Release/.../publish/.
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PSLauncher"
#define MyAppVersion "0.5.0"
#define MyAppShortName "PS_Launcher"
#define MyAppVersion "1.0.13"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PSLauncher.exe"
#define MyUpdaterExeName "PSLauncher.Updater.exe"
#define MyAppExeName "PS_Launcher.exe"
#define MyUpdaterExeName "PS_Launcher.Updater.exe"
[Setup]
; AppId est UNIQUE pour PSLauncher — ne pas changer entre deux versions sinon
@@ -28,17 +28,24 @@ AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
; Installation system-wide dans Program Files. {autopf} résout vers
; "C:\Program Files" sur cible 64-bit (cf. ArchitecturesInstallIn64BitMode=x64
; ci-dessous). PrivilegesRequired=admin → UAC à l'install, et UAC à chaque
; self-update du launcher (LauncherSelfUpdater détecte automatiquement le
; dossier non-writable et lance PS_Launcher.Updater.exe avec Verb="runas").
; Le staging du nouveau .exe avant swap reste dans %LocalAppData%\PSLauncher\
; selfupdate\, user-writable et lisible depuis l'updater élevé.
DefaultDirName={autopf}\{#MyAppPublisher}\{#MyAppShortName}
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
OutputDir=output
OutputBaseFilename=PSLauncher-Setup-{#MyAppVersion}
SetupIconFile=
OutputBaseFilename=PS_Launcher-Setup-{#MyAppVersion}
SetupIconFile=..\src\PSLauncher.App\Resources\favicon.ico
Compression=lzma2/ultra
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
PrivilegesRequiredOverridesAllowed=dialog
UsedUserAreasWarning=no
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
UninstallDisplayName={#MyAppName}
@@ -57,18 +64,72 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription
[Files]
Source: "..\src\PSLauncher.App\bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\src\PSLauncher.Updater\bin\Release\net8.0-windows\win-x64\publish\{#MyUpdaterExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\docs\PS_Launcher-Guide-Utilisateur.pptx"; DestDir: "{app}\docs"; Flags: ignoreversion
; WebView2 Evergreen Bootstrapper (Microsoft, redistribuable). 1.6 MB.
; Téléchargé depuis https://go.microsoft.com/fwlink/p/?LinkId=2124703
; Exécuté en silencieux à l'install SI WebView2 Runtime n'est pas déjà présent
; (vérification registre via la fonction Pascal IsWebView2Installed ci-dessous).
; Sans ça, sur un Win10/Server sans WebView2 préinstallé, la fenêtre du launcher
; reste toute blanche au démarrage parce que les contrôles WebView2 (Reports +
; Documentation) ne peuvent pas s'initialiser et plantent en silence le rendu UI.
Source: "redists\MicrosoftEdgeWebview2Setup.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall; Check: not IsWebView2Installed
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\Guide utilisateur"; Filename: "{app}\docs\PS_Launcher-Guide-Utilisateur.pptx"; IconFilename: "{app}\{#MyAppExeName}"
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
; Installe WebView2 Runtime si pas déjà présent. /silent /install = install
; non-interactif. Le bootstrapper télécharge ~150 MB depuis Microsoft (1-2 min
; selon la connexion). StatusMsg pour que l'utilisateur voie ce qu'il se passe.
; Code exit attendu : 0 (déjà installé OU install réussie). On ne fail pas le
; setup global même si ça échoue — le launcher logue ensuite l'absence si ça
; pose problème, plutôt que de bloquer toute l'installation pour ça.
Filename: "{tmp}\MicrosoftEdgeWebview2Setup.exe"; Parameters: "/silent /install"; StatusMsg: "Installation du composant Microsoft Edge WebView2 Runtime (~150 MB, peut prendre 1-2 min)..."; Check: not IsWebView2Installed; Flags: waituntilterminated
; Règles firewall pour le cache LAN P2P. Profile=private,domain (PAS public) =
; safety net : même si l'utilisateur connecte le PC à un Wi-Fi public, le port
; reste fermé. Le filtre RFC1918 dans LanCacheServer.cs est la première barrière,
; ces règles sont la deuxième. runhidden = pas de console visible à l'install.
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PSLauncher LAN Cache HTTP"" dir=in action=allow program=""{app}\{#MyAppExeName}"" protocol=TCP localport=47623 profile=private,domain"; Flags: runhidden
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PSLauncher LAN Discovery"" dir=in action=allow program=""{app}\{#MyAppExeName}"" protocol=UDP localport=47624 profile=private,domain"; Flags: runhidden
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
[UninstallRun]
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PSLauncher LAN Cache HTTP"""; Flags: runhidden
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PSLauncher LAN Discovery"""; Flags: runhidden
[UninstallDelete]
; Ne touche PAS au cache utilisateur (%LocalAppData%\PSLauncher) ni aux versions installées
; (qui peuvent vivre dans n'importe quel installRoot configuré). On supprime uniquement les
; binaires installés et le dossier d'install s'il est vide après.
Type: filesandordirs; Name: "{app}\*.bak"
Type: dirifempty; Name: "{app}"
[Code]
// Détecte si Microsoft Edge WebView2 Runtime est déjà installé sur le système.
// Référence : https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution
//
// 3 emplacements registre possibles selon le contexte d'install du runtime :
// - HKLM 64-bit (machine-wide, install par admin)
// - HKLM 32-bit (machine-wide, parfois utilisé par les versions plus anciennes)
// - HKCU (user-only, install par utilisateur sans admin)
//
// La clé contient une valeur 'pv' avec la version installée. Présence non vide
// = runtime présent. Le check Edge classique (HKLM\...\Edge\BLBeacon) ne suffit
// pas parce qu'Edge stable ≠ WebView2 Runtime (deux composants distincts).
function IsWebView2Installed(): Boolean;
var
PV: String;
begin
Result := False;
if RegQueryStringValue(HKLM, 'SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
Result := True
else if RegQueryStringValue(HKLM, 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
Result := True
else if RegQueryStringValue(HKCU, 'Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
Result := True;
end;

View File

@@ -1,19 +1,48 @@
# Build complet : publish + Inno Setup
#
# Pré-requis :
# Pre-requis :
# - .NET 8 SDK
# - Inno Setup 6.x (https://jrsoftware.org/isdl.php)
#
# Lancer depuis la racine du repo :
# powershell -ExecutionPolicy Bypass -File installer\build-installer.ps1
#
# NB : ce fichier est volontairement sans accents pour rester compatible
# PowerShell 5.1 (qui lit les .ps1 sans BOM en Windows-1252, pas UTF-8).
param(
[string]$ISCC = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
[string]$ISCC = ""
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
# Resolution de ISCC.exe : argument explicite > emplacements connus > PATH.
function Find-Iscc {
param([string]$Hint)
if ($Hint -and (Test-Path $Hint)) { return $Hint }
# Couvre les installs system-wide (Program Files) et user-mode
# (%LocalAppData%\Programs, emplacement par defaut quand winget installe sans admin).
$candidates = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe",
"C:\Program Files (x86)\Inno Setup 5\ISCC.exe",
"C:\Program Files\Inno Setup 5\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 5\ISCC.exe"
)
foreach ($c in $candidates) {
if (Test-Path $c) { return $c }
}
$cmd = Get-Command iscc -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
return $null
}
Write-Host "==> dotnet publish PSLauncher.App (Release single-file)" -ForegroundColor Cyan
dotnet publish "$root\src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo
if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.App failed" }
@@ -22,12 +51,26 @@ Write-Host "==> dotnet publish PSLauncher.Updater (Release single-file)" -Foregr
dotnet publish "$root\src\PSLauncher.Updater\PSLauncher.Updater.csproj" -c Release --nologo
if ($LASTEXITCODE -ne 0) { throw "publish PSLauncher.Updater failed" }
if (-not (Test-Path $ISCC)) {
throw "ISCC.exe introuvable à $ISCC. Installe Inno Setup ou passe -ISCC <chemin>."
$resolved = Find-Iscc $ISCC
if (-not $resolved) {
Write-Host ""
Write-Host "ERREUR : Inno Setup (ISCC.exe) introuvable." -ForegroundColor Red
Write-Host ""
Write-Host "Installe Inno Setup 6 depuis :" -ForegroundColor Yellow
Write-Host " https://jrsoftware.org/isdl.php" -ForegroundColor Yellow
Write-Host ""
Write-Host "Ou installation silencieuse via winget :" -ForegroundColor Yellow
Write-Host " winget install JRSoftware.InnoSetup" -ForegroundColor Yellow
Write-Host ""
Write-Host "Une fois installe, relance ce script. Si ISCC.exe est dans un emplacement" -ForegroundColor Yellow
Write-Host "non standard, passe le chemin via -ISCC :" -ForegroundColor Yellow
Write-Host " .\installer\build-installer.ps1 -ISCC 'D:\Tools\Inno\ISCC.exe'" -ForegroundColor Yellow
Write-Host ""
throw "ISCC.exe introuvable. Inno Setup n'est pas installe sur cette machine."
}
Write-Host "==> Inno Setup compile" -ForegroundColor Cyan
& $ISCC "$PSScriptRoot\PSLauncher.iss"
Write-Host "==> Inno Setup compile (ISCC: $resolved)" -ForegroundColor Cyan
& $resolved "$PSScriptRoot\PSLauncher.iss"
if ($LASTEXITCODE -ne 0) { throw "ISCC failed" }
Write-Host ""

Binary file not shown.

View File

@@ -19,3 +19,6 @@ ndom_bytes(32)) . PHP_EOL;'hmac_secret = 4343ec086ef4fafb7c0a575e56ac1b83b5eb29c
Secret2: jwt_secret
php -r 'echo "jwt_secret = " . bin2hex(random_bytes(32)) . PHP_EOL;'
jwt_secret = 3bf50765f8af638c143c137e0f1ec539db0bd761a11af602db0a5c000a3872ea
Clé : PRSRV-WY4S-S485-D2WD-D7YP
Clé2 : PRSRV-V652-MPFX-7FJ3-L9QJ

View File

@@ -1,6 +1,12 @@
RewriteEngine On
RewriteBase /PS_Launcher/
# Forward du header Authorization vers PHP — Apache FastCGI le strippe par défaut
# sur OVH mutualisé. Sans ça, $_SERVER['HTTP_AUTHORIZATION'] est vide quand le
# client envoie "Authorization: Bearer ...".
RewriteCond %{HTTP:Authorization} ^(.+)$
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%1,L=0]
# HTTPS forcé (si le mutualisé OVH ne le force pas déjà)
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]

1
server/admin/.gitkeep Normal file
View File

@@ -0,0 +1 @@
# Placeholder — les vrais ZIPs (proserve-X.Y.Z.zip) sont uploadés en SFTP, pas en git.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -130,3 +130,163 @@ details[open] { background: var(--bg-elevated); padding: 8px; border-radius: 4px
a { color: var(--accent); }
a:hover { text-decoration: underline; }
/* ============================================================
Modal (<dialog>) — éditeur d'une entrée du manifest
Remplace les <details> inline qui débordaient horizontalement
============================================================ */
/* Styles visuels appliqués au dialog OUVERT ([open] est posé par
showModal() automatiquement). Sans le sélecteur [open], les règles
`display: flex` etc. écrasaient le `display: none` du user-agent
stylesheet → tous les dialogs apparaissaient inline au lieu d'être
cachés tant que pas explicitement ouverts. */
dialog.edit-modal[open] {
background: var(--bg-card); color: var(--text);
border: 1px solid var(--border); border-radius: 8px;
padding: 0; margin: auto;
width: min(640px, 92vw); max-height: 90vh;
overflow: hidden;
display: flex; flex-direction: column;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
}
dialog.edit-modal::backdrop {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
}
dialog.edit-modal > header {
padding: 14px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px;
background: var(--bg-elevated);
}
dialog.edit-modal > header h3 {
margin: 0; font-size: 15px; font-weight: 600;
flex: 1; color: var(--text);
}
dialog.edit-modal > header .close-x {
background: transparent; border: none;
color: var(--text-secondary); cursor: pointer;
font-size: 20px; line-height: 1; padding: 4px 8px;
border-radius: 4px;
}
dialog.edit-modal > header .close-x:hover {
background: var(--bg-card); color: var(--text);
}
/* Onglets verticaux compacts en tête */
.modal-tabs {
display: flex; gap: 4px; padding: 8px 12px 0 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
flex-wrap: wrap;
}
.modal-tabs button {
background: transparent; border: none;
color: var(--text-secondary); cursor: pointer;
font-size: 13px; font-weight: 500;
padding: 8px 14px; border-radius: 4px 4px 0 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.modal-tabs button:hover {
color: var(--text); background: var(--bg-elevated);
}
.modal-tabs button.active {
color: var(--text); border-bottom-color: var(--accent);
}
.modal-body {
flex: 1; overflow-y: auto;
padding: 20px;
}
.modal-body > section[hidden] { display: none; }
.modal-body > section { display: block; }
.modal-body .field:last-child { margin-bottom: 0; }
.modal-body .hint {
color: var(--text-secondary); font-size: 11px;
margin: 4px 0 0 0; line-height: 1.4;
}
.modal-body .warn {
color: #FBBF24; font-size: 11px;
margin: 4px 0 0 0; line-height: 1.4;
}
.modal-footer {
padding: 12px 20px;
border-top: 1px solid var(--border);
background: var(--bg-elevated);
display: flex; justify-content: flex-end; gap: 8px;
}
/* Tooltip-style note in the modal sections explaining a section purpose */
.section-intro {
background: var(--bg-input); border-left: 3px solid var(--accent);
padding: 10px 14px; border-radius: 4px;
font-size: 12px; color: var(--text-secondary);
margin-bottom: 16px; line-height: 1.5;
}
/* Liste de checkboxes verticale propre (pour Channels) */
.checkbox-list {
display: flex; flex-direction: column; gap: 2px;
}
.checkbox-list label {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 4px;
font-size: 13px; color: var(--text); font-weight: normal;
cursor: pointer; margin-bottom: 0;
}
.checkbox-list label:hover { background: var(--bg-elevated); }
.checkbox-list label code { flex: 0 0 auto; }
.checkbox-list label .muted { margin-left: auto; }
/* Actions inline d'une row de version : harmonise les hauteurs et l'espacement */
.row-actions {
display: inline-flex; gap: 6px; align-items: center;
flex-wrap: nowrap; justify-content: flex-end;
}
.row-actions .btn { white-space: nowrap; }
/* ============================================================
Overlay blocant affiché pendant les opérations longues
(re-hash d'un ZIP de 13 Go = 3-5 min côté serveur OVH).
Visible tant que le browser n'a pas reçu la réponse POST,
disparaît naturellement au reload de page suivant.
============================================================ */
.busy-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(4px);
z-index: 9999;
display: flex; align-items: center; justify-content: center;
}
.busy-overlay[hidden] { display: none; }
.busy-content {
background: var(--bg-card);
border: 1px solid var(--border);
padding: 40px 56px;
border-radius: 12px;
text-align: center;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.busy-content h3 {
margin: 0 0 10px;
font-size: 18px;
color: var(--text);
text-transform: none; letter-spacing: 0;
}
.busy-content p {
margin: 6px 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
}
.busy-spinner {
width: 56px; height: 56px;
border: 4px solid var(--bg-elevated);
border-top-color: var(--accent);
border-radius: 50%;
animation: busy-spin 1s linear infinite;
margin: 0 auto 24px;
}
@keyframes busy-spin { to { transform: rotate(360deg); } }

231
server/admin/channels.php Normal file
View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels;
Auth::requireLogin();
$config = require __DIR__ . '/../api/config.php';
$manifestDir = dirname(__DIR__) . '/manifest';
$message = null; $messageType = 'success';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf();
$action = $_POST['action'] ?? '';
try {
$registry = Channels::load($manifestDir);
if ($action === 'create') {
$rawName = (string)($_POST['name'] ?? '');
$name = Channels::normalizeName($rawName);
$label = trim((string)($_POST['label'] ?? '')) ?: $name;
$desc = trim((string)($_POST['description'] ?? '')) ?: null;
if (!Channels::isValidName($name)) {
throw new \RuntimeException("Nom invalide après normalisation : « {$rawName} » → « {$name} ». Saisis quelque chose comme « asterion-vr » ou « police ».");
}
if ($name === 'default') {
throw new \RuntimeException("Le channel « default » est implicite, on ne peut pas en créer un autre.");
}
foreach ($registry['channels'] as $c) {
if (($c['name'] ?? '') === $name) {
throw new \RuntimeException("Channel « {$name} » existe déjà.");
}
}
$entry = ['name' => $name, 'label' => $label];
if ($desc !== null) $entry['description'] = $desc;
$registry['channels'][] = $entry;
Channels::save($manifestDir, $registry);
$message = "Channel « {$name} » créé.";
if ($name !== $rawName) {
$message .= " (Nom normalisé depuis « {$rawName} ».)";
}
}
elseif ($action === 'edit') {
$name = trim((string)($_POST['name'] ?? ''));
$label = trim((string)($_POST['label'] ?? '')) ?: $name;
$desc = trim((string)($_POST['description'] ?? '')) ?: null;
$found = false;
foreach ($registry['channels'] as &$c) {
if (($c['name'] ?? '') === $name) {
$c['label'] = $label;
if ($desc === null) unset($c['description']);
else $c['description'] = $desc;
$found = true;
break;
}
}
unset($c);
if (!$found) throw new \RuntimeException("Channel « {$name} » introuvable.");
Channels::save($manifestDir, $registry);
$message = "Channel « {$name} » mis à jour.";
}
elseif ($action === 'delete') {
$name = trim((string)($_POST['name'] ?? ''));
if ($name === 'default') throw new \RuntimeException("Le channel « default » ne peut pas être supprimé.");
// Sécurité : empêche la suppression si des versions ou licenses
// référencent encore ce channel — sinon ces refs deviennent silencieusement
// pendantes et l'admin oublie pourquoi tel client ne voit rien.
$usage = checkChannelUsage($manifestDir, $name);
if ($usage['versions'] > 0 || $usage['licenses'] > 0) {
throw new \RuntimeException(
"Impossible de supprimer « {$name} » : "
. "{$usage['versions']} version(s) taggée(s) et {$usage['licenses']} license(s) attribuée(s) à ce channel. "
. "Détague d'abord les versions (page Versions) et change les licenses concernées (page Licenses)."
);
}
$registry['channels'] = array_values(array_filter(
$registry['channels'],
fn($c) => ($c['name'] ?? '') !== $name
));
Channels::save($manifestDir, $registry);
$message = "Channel « {$name} » supprimé.";
}
} catch (\Throwable $e) {
$message = $e->getMessage();
$messageType = 'error';
}
}
/**
* Compte les versions et licenses qui pointent encore sur un channel.
* Sert de garde-fou avant suppression.
*/
function checkChannelUsage(string $manifestDir, string $channelName): array
{
$usage = ['versions' => 0, 'licenses' => 0];
$manifestPath = $manifestDir . '/versions.json';
if (is_file($manifestPath)) {
$m = json_decode(file_get_contents($manifestPath), true);
if (is_array($m) && isset($m['versions'])) {
foreach ($m['versions'] as $v) {
$channels = $v['channels'] ?? [];
if (in_array($channelName, $channels, true)) $usage['versions']++;
}
}
}
try {
$config = require __DIR__ . '/../api/config.php';
$db = \PSLauncher\Db::get($config);
$stmt = $db->prepare('SELECT COUNT(*) FROM licenses WHERE channel = ?');
$stmt->execute([$channelName]);
$usage['licenses'] = (int)$stmt->fetchColumn();
} catch (\Throwable) { /* DB not reachable from this context, best effort */ }
return $usage;
}
require_once __DIR__ . '/../api/lib/Db.php';
$registry = Channels::load($manifestDir);
Layout::header('Channels', 'channels');
?>
<h1>Channels de distribution</h1>
<?php Layout::flash($message, $messageType); ?>
<div class="card">
<h2>Comment ça marche</h2>
<ul class="muted">
<li><strong>default</strong> = channel public implicite. Toutes les versions taggées « default » (ou sans tag) sont visibles par tous les clients, peu importe leur license.</li>
<li>Les autres channels (<strong>asterion-vr</strong>, <strong>police</strong>, …) sont des canaux privés. Une version taggée uniquement sur un channel privé n'est vue QUE par les licenses attribuées à ce channel.</li>
<li>Une version peut être taggée sur plusieurs channels en même temps (multi-channel). Une license n'est attribuée qu'à un seul channel à la fois.</li>
<li>Sémantique additive : un client sur le channel « police » voit les versions « default » + les versions « police ». Pour rendre une version invisible au public, ne la tague PAS « default ».</li>
</ul>
</div>
<div class="card">
<h2>Créer un channel</h2>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="create">
<div class="row">
<div class="col field">
<label>Nom (identifiant ASCII)</label>
<input type="text" name="name" required maxlength="80" placeholder="asterion-vr"
title="Sera normalisé en lowercase + tirets côté serveur (« ASTERION VR » → « asterion-vr »).">
</div>
<div class="col field">
<label>Label (affiché dans les UI)</label>
<input type="text" name="label" maxlength="120" placeholder="ASTERION VR (interne)">
</div>
</div>
<div class="field">
<label>Description (optionnel)</label>
<input type="text" name="description" maxlength="255" placeholder="Builds internes pour l'équipe ASTERION">
</div>
<button class="btn btn-success" type="submit">Créer</button>
</form>
</div>
<div class="card">
<h2>Channels existants</h2>
<table>
<thead>
<tr>
<th>Nom</th>
<th>Label</th>
<th>Description</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($registry['channels'] as $c):
$isDefault = ($c['name'] ?? '') === 'default';
?>
<tr>
<td>
<code><?= htmlspecialchars($c['name'] ?? '') ?></code>
<?php if ($isDefault): ?>
<span class="badge badge-success" style="margin-left: 6px;" title="Channel implicite, immutable">implicite</span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($c['label'] ?? $c['name'] ?? '') ?></td>
<td class="muted"><?= htmlspecialchars($c['description'] ?? '') ?></td>
<td style="text-align:right; white-space: nowrap;">
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Éditer</summary>
<form method="post" style="margin-top: 8px; min-width: 320px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="edit">
<input type="hidden" name="name" value="<?= htmlspecialchars($c['name'] ?? '') ?>">
<div class="field">
<label>Label</label>
<input type="text" name="label" value="<?= htmlspecialchars($c['label'] ?? '') ?>" required>
</div>
<div class="field">
<label>Description</label>
<input type="text" name="description" value="<?= htmlspecialchars($c['description'] ?? '') ?>">
</div>
<button class="btn btn-primary" type="submit">Enregistrer</button>
</form>
</details>
<?php if (!$isDefault): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Supprimer le channel <?= htmlspecialchars($c['name']) ?> ?\nLes versions et licenses qui le référencent doivent être détaguées d\'abord.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="delete">
<input type="hidden" name="name" value="<?= htmlspecialchars($c['name'] ?? '') ?>">
<button class="btn btn-danger" type="submit">Supprimer</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php Layout::footer();

199
server/admin/launcher.php Normal file
View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
Auth::requireLogin();
$config = require __DIR__ . '/../api/config.php';
$root = dirname(__DIR__);
$manifestPath = "$root/manifest/versions.json";
$buildsDir = "$root/builds";
$message = null; $messageType = 'success';
function loadManifest(string $path): array
{
if (!is_file($path)) return ['schemaVersion' => 1, 'versions' => []];
return json_decode(file_get_contents($path), true) ?? [];
}
function saveManifest(string $path, array $manifest): void
{
// usort attend une référence — on ne peut pas lui passer `?? []` en PHP 8.
if (isset($manifest['versions']) && is_array($manifest['versions'])) {
usort($manifest['versions'], fn($a, $b) => version_compare($b['version'], $a['version']));
}
file_put_contents(
$path,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"
);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf();
$action = $_POST['action'] ?? '';
$manifest = loadManifest($manifestPath);
try {
if ($action === 'set_launcher') {
$lver = trim($_POST['launcher_version'] ?? '');
$lmin = trim($_POST['launcher_min_required'] ?? '');
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lver)) {
throw new Exception('Numéro de version launcher invalide (X.Y.Z ou X.Y.Z.B attendu).');
}
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lmin)) {
throw new Exception('Numéro de version "minRequired" invalide.');
}
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
$manifest['launcher'] = [
'version' => $lver,
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
'download' => [
'url' => "{$base}/builds/launcher/PS_Launcher-{$lver}.exe",
'sizeBytes' => 0,
'sha256' => 'REPLACE_AFTER_BUILD',
],
'releaseNotesUrl' => null,
];
saveManifest($manifestPath, $manifest);
$message = "Section launcher mise à jour (v{$lver}). Upload PS_Launcher-{$lver}.exe dans builds/launcher/ puis clique « Hasher le launcher + signer ».";
}
elseif ($action === 'remove_launcher') {
unset($manifest['launcher']);
saveManifest($manifestPath, $manifest);
$message = "Section launcher retirée du manifest (auto-update désactivé).";
}
elseif ($action === 'sync_launcher') {
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$force = !empty($_POST['force']);
$result = $signer->run('launcher', $force);
$forceLabel = $force ? ' [FORCE: cache ignoré]' : '';
$message = "Sortie du script (scope: launcher{$forceLabel}) :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
}
// (Le settings lock est maintenant par-license, géré dans licenses.php — voir migration 003.)
} catch (Exception $e) {
$message = $e->getMessage();
$messageType = 'error';
}
}
$manifest = loadManifest($manifestPath);
$launcher = $manifest['launcher'] ?? null;
$launcherFiles = is_dir("$buildsDir/launcher")
? array_map('basename', glob("$buildsDir/launcher/*.exe") ?: [])
: [];
$launcherZipPresent = false;
$launcherHashed = false;
if ($launcher) {
$expectedFile = basename(parse_url($launcher['download']['url'] ?? '', PHP_URL_PATH) ?? '');
$launcherZipPresent = in_array($expectedFile, $launcherFiles, true);
$launcherHashed = !empty($launcher['download']['sha256'])
&& !str_starts_with($launcher['download']['sha256'], 'REPLACE');
}
Layout::header('Launcher', 'launcher');
?>
<h1>Launcher — auto-update</h1>
<?php Layout::flash($message, $messageType); ?>
<div class="card">
<h2>État actuel</h2>
<?php if ($launcher): ?>
<p>
Version annoncée : <strong>v<?= htmlspecialchars($launcher['version']) ?></strong>
• minRequired : <code><?= htmlspecialchars($launcher['minRequired'] ?? '—') ?></code>
• Exe : <?= $launcherZipPresent
? "<span class='badge badge-success'>présent</span>"
: "<span class='badge badge-warning'>absent</span>" ?>
• Hash : <?= $launcherHashed
? "<span class='badge badge-success'>OK</span>"
: "<span class='badge badge-warning'>à calculer</span>" ?>
• Signature manifest : <?= empty($manifest['signature'])
? "<span class='badge badge-warning'>NON SIGNÉ</span>"
: "<span class='badge badge-success'>SIGNÉ Ed25519</span>" ?>
</p>
<p class="muted">URL attendue de l'exe : <code><?= htmlspecialchars($launcher['download']['url']) ?></code></p>
<?php else: ?>
<p class="muted">Aucune version du launcher déclarée — l'auto-update est désactivé. Configure-la ci-dessous pour l'activer.</p>
<?php endif; ?>
</div>
<div class="card">
<h2>Workflow d'une nouvelle version du launcher</h2>
<ol class="muted">
<li>Remplis le formulaire <strong>Définir / mettre à jour</strong> ci-dessous (ex : <code>0.6.0</code>).</li>
<li>Sur ton poste : <code>dotnet publish src\PSLauncher.App -c Release</code> et <code>dotnet publish src\PSLauncher.Updater -c Release</code> (les exes sont copiés à la racine du repo).</li>
<li>Renomme la copie : <code>PS_Launcher.exe → PS_Launcher-X.Y.Z.exe</code>.</li>
<li>Upload via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
<li>Clique <strong>🔁 Hasher le launcher + signer</strong>.</li>
<li>Les launchers déjà déployés détecteront la nouvelle version au prochain « Vérifier les MAJ ».</li>
</ol>
</div>
<div class="card">
<h2>Définir / mettre à jour</h2>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_launcher">
<div class="row">
<div class="col field">
<label>Version launcher (X.Y.Z ou X.Y.Z.B)</label>
<input type="text" name="launcher_version" placeholder="0.6.0"
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+(\.\d+)?">
</div>
<div class="col field">
<label>Version minimale requise (X.Y.Z ou X.Y.Z.B)</label>
<input type="text" name="launcher_min_required" placeholder="0.5.0"
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+(\.\d+)?">
</div>
</div>
<button class="btn btn-success" type="submit">Définir</button>
</form>
<form method="post" style="margin-top: 16px;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="sync_launcher">
<button class="btn btn-primary" type="submit">🔁 Hasher le launcher + signer</button>
<label style="margin-left:12px;font-size:12px;color:#888" title="Recalcule le SHA-256 même si l'exe n'a pas changé.">
<input type="checkbox" name="force" value="1"> Force re-hash
</label>
</form>
<?php if ($launcher): ?>
<form method="post" style="margin-top: 16px;"
onsubmit="return confirm('Retirer la section launcher du manifest ? L\'auto-update sera désactivé.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="remove_launcher">
<button class="btn btn-danger" type="submit">Retirer la section launcher</button>
</form>
<?php endif; ?>
</div>
<div class="card">
<h2>Exes présents dans builds/launcher/</h2>
<?php if (empty($launcherFiles)): ?>
<p class="muted">Aucun exe pour l'instant. Upload les binaires via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</p>
<?php else: ?>
<table>
<thead><tr><th>Fichier</th><th>Taille</th></tr></thead>
<tbody>
<?php foreach ($launcherFiles as $f): ?>
<tr>
<td><code><?= htmlspecialchars($f) ?></code></td>
<td class="muted"><?= Layout::formatBytes(filesize("$buildsDir/launcher/$f")) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php Layout::footer();

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace PSLauncher\Admin;
/**
* Registre des channels de distribution. Stocké dans manifest/channels.json
* pour rester portable / pas dépendre de la DB pour cette config simple.
*
* Une entrée a 3 champs :
* - name : identifiant ASCII utilisé en code partout (whitelist a-z0-9_-)
* - label : libellé human-friendly affiché dans les UI admin
* - description : contexte libre pour l'admin (optionnel)
*
* Le channel "default" est implicite et toujours présent : il représente les
* versions publiques (vues par toutes les licenses, peu importe leur tag). Les
* versions sans tag explicite sont implicitement dans "default".
*
* Les licenses pointent vers UN channel — elles voient les versions taggées
* avec ce channel ET celles taggées "default" (sémantique additive).
*/
final class Channels
{
/** @return array{channels: array<int, array{name:string,label:string,description?:string}>} */
public static function load(string $manifestDir): array
{
$path = $manifestDir . '/channels.json';
$base = ['channels' => []];
if (is_file($path)) {
$raw = file_get_contents($path);
$json = json_decode((string)$raw, true);
if (is_array($json) && isset($json['channels']) && is_array($json['channels'])) {
$base['channels'] = $json['channels'];
}
}
// Garantit que "default" est toujours présent en tête (immutable depuis la UI).
$hasDefault = false;
foreach ($base['channels'] as $c) {
if (($c['name'] ?? '') === 'default') { $hasDefault = true; break; }
}
if (!$hasDefault) {
array_unshift($base['channels'], [
'name' => 'default',
'label' => 'Public',
'description' => 'Versions publiques visibles par tous les clients',
]);
}
return $base;
}
public static function save(string $manifestDir, array $registry): void
{
$path = $manifestDir . '/channels.json';
if (!is_dir($manifestDir)) mkdir($manifestDir, 0755, true);
file_put_contents(
$path,
json_encode($registry, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
);
}
/**
* Retourne true si <code>$name</code> est un identifiant de channel valide :
* lowercase, chiffres, tirets et underscores, 1 à 64 caractères.
*/
public static function isValidName(string $name): bool
{
return (bool)preg_match('/^[a-z0-9_-]{1,64}$/', $name);
}
/**
* Normalise un nom saisi par l'admin : lowercase, espaces → tirets, retire
* les chars hors whitelist, compresse les tirets multiples, trim.
* Identique à ps_normalize_channel() dans versions.php — centralisé ici
* pour les futurs ajouts.
*/
public static function normalizeName(string $raw): string
{
$s = strtolower(trim($raw));
$s = (string)preg_replace('/[\s\x{2010}-\x{2015}]+/u', '-', $s);
$s = (string)preg_replace('/[^a-z0-9_-]/', '', $s);
$s = (string)preg_replace('/-+/', '-', $s);
$s = trim($s, '-_');
return substr($s, 0, 64);
}
/**
* @return list<string> Liste des noms de channel pour les dropdowns.
*/
public static function listNames(string $manifestDir): array
{
$reg = self::load($manifestDir);
return array_map(fn($c) => (string)($c['name'] ?? ''), $reg['channels']);
}
}

View File

@@ -38,6 +38,8 @@ HTML;
'dashboard' => ['index.php', 'Dashboard'],
'licenses' => ['licenses.php', 'Licenses'],
'versions' => ['versions.php', 'Versions'],
'channels' => ['channels.php', 'Channels'],
'launcher' => ['launcher.php', 'Launcher'],
'audit' => ['audit.php', 'Audit'],
];
$links = '';

201
server/admin/lib/Mailer.php Normal file
View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace PSLauncher\Admin;
/**
* Petit helper d'envoi d'emails de notification (release announce).
*
* Stratégie : on utilise <code>mail()</code> natif PHP, qui sur OVH mutualisé
* passe par le relais SMTP local et marche out-of-the-box sans config
* additionnelle. Pas de dépendance composer (PHPMailer, etc.) volontairement
* — on a besoin de notifications opportunistes, pas de delivery garantie.
*
* Pour passer à PHPMailer / SMTP externe plus tard, la surface API reste la
* même : on remplace juste <see cref="sendInternal"/> par un appel
* <code>$mailer->send()</code>.
*/
final class Mailer
{
/**
* Parse un blob texte saisi par l'admin (CSV, ligne par ligne, mélange…)
* en liste d'emails normalisés et validés. Tout token qui ne matche pas
* la regex email est rejeté SILENCIEUSEMENT (l'admin n'a pas besoin d'un
* popup pour un copy/paste avec une virgule en trop).
*
* @return list<string> emails uniques lowercase
*/
public static function parseEmails(?string $raw): array
{
if ($raw === null || trim($raw) === '') return [];
// Split sur tout séparateur courant : virgule, point-virgule, espace,
// newline. Permet à l'admin de coller un copy/paste depuis Outlook,
// Excel, ou ligne par ligne — peu importe.
$tokens = preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$out = [];
foreach ($tokens as $t) {
$t = strtolower(trim($t));
if ($t === '') continue;
if (!filter_var($t, FILTER_VALIDATE_EMAIL)) continue;
if (!in_array($t, $out, true)) $out[] = $t;
}
return $out;
}
/**
* Inverse : prend un tableau d'emails et le formate pour stockage DB
* (séparé par virgules). NULL si la liste est vide pour économiser
* une ligne en DB sur les licenses sans contacts.
*/
public static function joinForStorage(array $emails): ?string
{
$emails = array_values(array_filter(
array_map('trim', $emails),
fn($e) => $e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)
));
return empty($emails) ? null : implode(', ', $emails);
}
/**
* Envoie un email HTML (avec partie texte alternative pour les MUA legacy)
* à un destinataire. Best-effort : retourne true en cas de succès apparent
* du sink mail(), false si rejet immédiat. Erreurs de delivery côté SMTP
* relais (bounce, deferred) ne sont PAS détectées ici — on log juste le
* résultat brut.
*
* Si <paramref name="$inlineImages"/> est non vide, on construit une structure
* MIME <c>multipart/related</c> (CID embed) au lieu de <c>multipart/alternative</c>.
* Les images sont attachées comme pièces jointes inline, référencées dans
* le HTML via <c>&lt;img src="cid:..."&gt;</c>. Avantage vs hotlink HTTP :
* Outlook (et MUA généralement) affiche l'image SANS demander de permission
* (vs hotlink bloqué par défaut pour anti-tracking pixel).
*
* @param array $notifConfig bloc $config['notifications'] (from_address, from_name, reply_to)
* @param string $to destinataire UNIQUE (pour multi-destinataires, appeler en boucle)
* @param string $subject sujet en clair (sera Q-encoded UTF-8 automatiquement)
* @param string $bodyHtml contenu HTML (multipart alt avec text auto-généré)
* @param array $inlineImages liste d'images à embed inline. Chaque entrée :
* ['cid' => 'logo', 'path' => '/abs/path/file.png', 'type' => 'image/png']
*/
public static function send(array $notifConfig, string $to, string $subject, string $bodyHtml, array $inlineImages = []): bool
{
$from = $notifConfig['from_address'] ?? 'no-reply@localhost';
$fromName = $notifConfig['from_name'] ?? 'PROSERVE Launcher';
$replyTo = $notifConfig['reply_to'] ?? '';
// Filtre les inline images : ne garde que celles dont le fichier existe
// ET est lisible. Si une image manque, on log silencieusement et on
// continue avec les autres — l'email part de toute façon.
$validImages = [];
foreach ($inlineImages as $img) {
$p = $img['path'] ?? '';
if ($p === '' || !is_file($p) || !is_readable($p)) continue;
$validImages[] = $img;
}
// Boundaries uniques. Si on a des images, on a 2 niveaux d'imbrication :
// multipart/related contenant multipart/alternative + image(s).
$boundaryAlt = '=_PSLauncherAlt_' . bin2hex(random_bytes(8));
$boundaryRel = '=_PSLauncherRel_' . bin2hex(random_bytes(8));
// Headers RFC 5322 + MIME. Pas de "Bcc:" — on appelle send() par destinataire
// pour avoir des "Per-user emails" et faciliter le debugging.
$contentType = empty($validImages)
? "multipart/alternative; boundary=\"{$boundaryAlt}\""
: "multipart/related; type=\"multipart/alternative\"; boundary=\"{$boundaryRel}\"";
$headers = [
'MIME-Version: 1.0',
"Content-Type: {$contentType}",
'From: ' . self::formatAddress($fromName, $from),
'X-Mailer: PSLauncher-Admin',
'X-Auto-Response-Suppress: All', // mute autoresponders OOO
];
if ($replyTo !== '' && filter_var($replyTo, FILTER_VALIDATE_EMAIL)) {
$headers[] = 'Reply-To: ' . $replyTo;
}
// === Construction du body ===
$bodyText = self::htmlToPlainText($bodyHtml);
// Bloc multipart/alternative (text + html)
$altPart = "--{$boundaryAlt}\r\n";
$altPart .= "Content-Type: text/plain; charset=UTF-8\r\n";
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$altPart .= $bodyText . "\r\n\r\n";
$altPart .= "--{$boundaryAlt}\r\n";
$altPart .= "Content-Type: text/html; charset=UTF-8\r\n";
$altPart .= "Content-Transfer-Encoding: 8bit\r\n\r\n";
$altPart .= $bodyHtml . "\r\n\r\n";
$altPart .= "--{$boundaryAlt}--\r\n";
if (empty($validImages)) {
// Pas d'images → email simple, juste l'alternative
$body = $altPart;
} else {
// Avec images → multipart/related wrappant l'alt + chaque image
$body = "--{$boundaryRel}\r\n";
$body .= "Content-Type: multipart/alternative; boundary=\"{$boundaryAlt}\"\r\n\r\n";
$body .= $altPart;
foreach ($validImages as $img) {
$cid = $img['cid'] ?? bin2hex(random_bytes(6));
$type = $img['type'] ?? 'application/octet-stream';
$path = $img['path'];
$name = basename($path);
$data = @file_get_contents($path);
if ($data === false) continue;
$b64 = chunk_split(base64_encode($data), 76, "\r\n");
$body .= "\r\n--{$boundaryRel}\r\n";
$body .= "Content-Type: {$type}; name=\"{$name}\"\r\n";
$body .= "Content-Transfer-Encoding: base64\r\n";
$body .= "Content-ID: <{$cid}>\r\n";
$body .= "Content-Disposition: inline; filename=\"{$name}\"\r\n\r\n";
$body .= $b64;
}
$body .= "\r\n--{$boundaryRel}--\r\n";
}
$subjectEncoded = '=?UTF-8?B?' . base64_encode($subject) . '?=';
try {
return @mail($to, $subjectEncoded, $body, implode("\r\n", $headers));
} catch (\Throwable $e) {
return false;
}
}
/**
* Convertit un HTML simple en plain text pour la partie text/plain du
* multipart. Pas une vraie conversion (ce n'est pas html2text), mais
* suffisant pour nos templates qui sont quasi-text avec quelques tags.
*/
private static function htmlToPlainText(string $html): string
{
// Convert <br> et <p> en sauts de ligne avant strip_tags
$s = preg_replace('#<br\s*/?>#i', "\n", $html) ?? $html;
$s = preg_replace('#</p>\s*<p[^>]*>#i', "\n\n", $s) ?? $s;
$s = preg_replace('#<a\s[^>]*href="([^"]+)"[^>]*>([^<]+)</a>#i', '$2 ($1)', $s) ?? $s;
$s = strip_tags($s);
$s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim($s);
}
/**
* "Pretty Name <email@x>" si name non vide, sinon "email@x" brut.
* Échappe les chars spéciaux conformément à RFC 5322.
*/
private static function formatAddress(string $name, string $email): string
{
$name = trim($name);
if ($name === '') return $email;
// Encode le name en MIME si pas pur ASCII
if (preg_match('/[\x80-\xFF]/', $name)) {
$name = '=?UTF-8?B?' . base64_encode($name) . '?=';
} else {
// Quote si chars spéciaux RFC : "()<>@,;:\\\"/.\\[\\]"
if (preg_match('/[()<>@,;:\\\"\/\[\]]/', $name)) {
$name = '"' . str_replace('"', '\\"', $name) . '"';
}
}
return "{$name} <{$email}>";
}
}

View File

@@ -2,10 +2,14 @@
declare(strict_types=1);
require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php';
require __DIR__ . '/lib/Mailer.php';
require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels;
use PSLauncher\Admin\Mailer;
use PSLauncher\Db;
Auth::requireLogin();
@@ -27,6 +31,25 @@ function generateLicenseKey(): string
return 'PRSRV-' . implode('-', $groups);
}
/**
* Liste les channels disponibles depuis le registre channels.json. Le channel
* « default » apparaît avec une clé vide (= NULL en DB → comportement legacy
* « pas de tag, voit que default »).
*/
function listAvailableChannels(): array
{
$manifestDir = dirname(__DIR__) . '/manifest';
$registry = Channels::load($manifestDir);
$out = ['' => '(aucun — voit uniquement « default »)'];
foreach ($registry['channels'] as $c) {
$name = (string)($c['name'] ?? '');
if ($name === '' || $name === 'default') continue; // default est l'option implicite (clé vide)
$label = $c['label'] ?? $name;
$out[$name] = "{$name}{$label}";
}
return $out;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf();
$action = $_POST['action'] ?? '';
@@ -37,6 +60,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$until = trim($_POST['until'] ?? '');
$maxMachines = max(1, (int)($_POST['max_machines'] ?? 1));
$notes = trim($_POST['notes'] ?? '') ?: null;
// Channel = '' ↦ NULL en DB (default manifest). Whitelist regex anti-injection.
$channel = trim((string)($_POST['channel'] ?? ''));
if ($channel === '') {
$channel = null;
} elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
throw new Exception('Channel invalide : seules les lettres minuscules, chiffres, _ et - sont autorisés (max 64 caractères).');
}
$canSeeBetas = isset($_POST['can_see_betas']) ? 1 : 0;
if ($owner === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) {
throw new Exception('Nom du client et date d\'expiration sont requis (date au format YYYY-MM-DD).');
@@ -45,8 +76,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
for ($attempts = 0; $attempts < 5; $attempts++) {
$newKey = generateLicenseKey();
try {
$db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, notes) VALUES (?, ?, NOW(), ?, ?, ?)')
->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $notes]);
$db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, channel, can_see_betas, notes) VALUES (?, ?, NOW(), ?, ?, ?, ?, ?)')
->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $channel, $canSeeBetas, $notes]);
$message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée.";
break;
} catch (PDOException $e) {
@@ -75,10 +106,116 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
->execute([$newUntil . ' 23:59:59', $id]);
$message = "License #{$id} prolongée jusqu'au {$newUntil}.";
}
elseif ($action === 'set_channel') {
$id = (int)($_POST['id'] ?? 0);
$channel = trim((string)($_POST['channel'] ?? ''));
if ($channel === '') {
$channel = null; // default manifest
} elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
throw new Exception('Channel invalide.');
}
$db->prepare('UPDATE licenses SET channel = ? WHERE id = ?')->execute([$channel, $id]);
$label = $channel ?? 'default';
$message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
}
elseif ($action === 'toggle_betas') {
// Renommé semantically (toggle → set explicit) sans casser le nom
// de l'action POST pour préserver les éventuels liens externes
// existants. La modal envoie maintenant `enabled=1` si checkbox
// cochée, sinon le param est absent (= 0). Plus de flip aveugle.
$id = (int)($_POST['id'] ?? 0);
$enabled = isset($_POST['enabled']) ? 1 : 0;
$db->prepare('UPDATE licenses SET can_see_betas = ? WHERE id = ?')->execute([$enabled, $id]);
$message = $enabled
? "License #{$id} : accès aux versions BÊTA activé."
: "License #{$id} : accès aux versions BÊTA désactivé.";
}
elseif ($action === 'set_max_machines') {
$id = (int)($_POST['id'] ?? 0);
$newMax = (int)($_POST['max_machines'] ?? 0);
if ($id <= 0 || $newMax < 1 || $newMax > 100) {
throw new Exception('Nombre de machines invalide (1100).');
}
// Refus de descendre sous le nombre de machines déjà activées —
// sinon état incohérent (machines_count > max_machines). L'admin doit
// d'abord libérer des slots via "Libérer" individuel ou "Libérer toutes".
$stmt = $db->prepare('SELECT COUNT(*) FROM license_machines WHERE license_id = ?');
$stmt->execute([$id]);
$current = (int)$stmt->fetchColumn();
if ($newMax < $current) {
throw new Exception("Impossible : {$current} machines sont déjà actives. Libère des slots d'abord ou choisis ≥ {$current}.");
}
$db->prepare('UPDATE licenses SET max_machines = ? WHERE id = ?')->execute([$newMax, $id]);
$message = "License #{$id} : limite passée à {$newMax} machine(s).";
}
elseif ($action === 'set_settings_lock_password') {
$id = (int)($_POST['id'] ?? 0);
$pwd = (string)($_POST['settings_lock_password'] ?? '');
if ($id <= 0) {
throw new Exception('License id invalide.');
}
if ($pwd === '') {
// Vide = retire le verrouillage pour cette license
$db->prepare('UPDATE licenses SET settings_lock_password_hash = NULL WHERE id = ?')->execute([$id]);
$message = "License #{$id} : verrouillage des paramètres avancés retiré.";
} else {
if (strlen($pwd) < 4) {
throw new Exception('Mot de passe trop court (minimum 4 caractères).');
}
// SHA-256 hex lowercase, format compatible avec SettingsLockService côté launcher.
$hash = hash('sha256', $pwd);
$db->prepare('UPDATE licenses SET settings_lock_password_hash = ? WHERE id = ?')->execute([$hash, $id]);
$message = "License #{$id} : mot de passe des paramètres avancés mis à jour.";
}
}
elseif ($action === 'set_contact_emails') {
// Met à jour la liste des emails de contact + la langue préférée
// pour les notifications de nouvelles versions. L'admin saisit en
// texte libre (1 par ligne, virgules, point-virgules — peu importe),
// on parse + valide via FILTER_VALIDATE_EMAIL et on stocke en CSV.
// Vide / aucun email valide → on stocke NULL (= pas de notif possible
// pour cette license, juste skip silencieux à l'envoi).
//
// Language : whitelist stricte fr/en/zh/th/ar (= ce que le launcher
// supporte côté Strings.cs). NULL ou inconnu → fallback English.
$id = (int)($_POST['id'] ?? 0);
$raw = (string)($_POST['contact_emails'] ?? '');
$lang = trim((string)($_POST['language'] ?? ''));
if ($id <= 0) {
throw new Exception('License id invalide.');
}
$allowedLangs = ['fr', 'en', 'es', 'de', 'zh', 'th', 'ar'];
if ($lang !== '' && !in_array($lang, $allowedLangs, true)) {
throw new Exception("Code langue invalide : '{$lang}'. Attendu : " . implode(', ', $allowedLangs) . ' ou vide.');
}
$parsed = Mailer::parseEmails($raw);
$stored = Mailer::joinForStorage($parsed);
$db->prepare('UPDATE licenses SET contact_emails = ?, language = ? WHERE id = ?')
->execute([$stored, $lang !== '' ? $lang : null, $id]);
$count = count($parsed);
$langDisplay = $lang !== '' ? $lang : 'en (défaut)';
$message = $count === 0
? "License #{$id} : contacts de notification supprimés. Langue email : {$langDisplay}."
: "License #{$id} : {$count} contact(s) email enregistré(s), langue email : {$langDisplay}.";
}
elseif ($action === 'reset_machines') {
$id = (int)($_POST['id'] ?? 0);
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
$message = "Machines libérées pour la license #{$id}.";
$message = "Toutes les machines libérées pour la license #{$id}.";
}
elseif ($action === 'remove_machine') {
$licenseId = (int)($_POST['license_id'] ?? 0);
$machineId = trim((string)($_POST['machine_id'] ?? ''));
if ($licenseId <= 0 || $machineId === '') {
throw new Exception('license_id et machine_id requis');
}
$stmt = $db->prepare('DELETE FROM license_machines WHERE license_id = ? AND machine_id = ?');
$stmt->execute([$licenseId, $machineId]);
$count = $stmt->rowCount();
$shortId = substr($machineId, 0, 12);
$message = $count > 0
? "Machine {$shortId}… libérée pour la license #{$licenseId}."
: "Aucune machine correspondante trouvée pour license #{$licenseId}.";
}
} catch (Exception $e) {
$message = $e->getMessage();
@@ -86,6 +223,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
$availableChannels = listAvailableChannels();
$licenses = $db->query(
'SELECT l.*,
(SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count
@@ -93,6 +232,18 @@ $licenses = $db->query(
ORDER BY l.id DESC'
)->fetchAll();
// Pré-fetch toutes les machines de toutes les licenses en une seule requête,
// indexées par license_id pour l'affichage par-row sans N+1.
$allMachines = $db->query(
'SELECT license_id, machine_id, machine_label, first_seen, last_seen
FROM license_machines
ORDER BY last_seen DESC'
)->fetchAll();
$machinesByLicense = [];
foreach ($allMachines as $m) {
$machinesByLicense[(int)$m['license_id']][] = $m;
}
Layout::header('Licenses', 'licenses');
?>
<h1>Licenses</h1>
@@ -126,6 +277,23 @@ Layout::header('Licenses', 'licenses');
<input type="number" name="max_machines" value="1" min="1" max="100">
</div>
</div>
<div class="row">
<div class="col field">
<label>Channel <span class="muted" style="font-weight: normal; font-size: 11px;">(quel manifest sert le client ?)</span></label>
<select name="channel">
<?php foreach ($availableChannels as $value => $label): ?>
<option value="<?= htmlspecialchars($value) ?>"><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col field">
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="can_see_betas" value="1" style="width: auto; margin: 0;">
Accès aux versions BÊTA
</label>
<span class="muted" style="font-size: 11px;">Coche pour les testeurs internes / clients pilotes.</span>
</div>
</div>
<div class="field">
<label>Notes internes (optionnel)</label>
<input type="text" name="notes" placeholder="Contact : jean@acme.com">
@@ -143,6 +311,7 @@ Layout::header('Licenses', 'licenses');
<th>Émise</th>
<th>Expire</th>
<th>Machines</th>
<th>Channel / Bêta</th>
<th>Statut</th>
<th style="text-align:right">Actions</th>
</tr>
@@ -159,50 +328,393 @@ Layout::header('Licenses', 'licenses');
<td>#<?= $l['id'] ?></td>
<td>
<strong><?= htmlspecialchars($l['owner_name']) ?></strong>
<?php // Clé license affichée en monospace + bouton 1-clic
// pour copier dans le presse-papier. Permet à l'admin
// de la transmettre rapidement à un client sans passer
// par phpMyAdmin. Sensibilité OK : la page admin est
// déjà derrière auth + la clé seule ne suffit pas
// (validation serveur HMAC + machine_id requis). ?>
<div style="font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px;">
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 11px; color: #C5E1A5; word-break: break-all;"><?= htmlspecialchars($l['license_key']) ?></code>
<button type="button" class="copy-btn"
data-copy="<?= htmlspecialchars($l['license_key'], ENT_QUOTES) ?>"
title="Copier la clé license"
style="background: transparent; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; padding: 2px 8px; border-radius: 3px; font-size: 11px; line-height: 1;">📋</button>
</div>
<?php if (!empty($l['notes'])): ?>
<div class="muted" style="font-size: 11px;"><?= htmlspecialchars($l['notes']) ?></div>
<div class="muted" style="font-size: 11px; margin-top: 4px;"><?= htmlspecialchars($l['notes']) ?></div>
<?php endif; ?>
</td>
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
<td><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></td>
<td><?= $l['machines_count'] ?> / <?= $l['max_machines'] ?></td>
<td>
<?php if ($l['machines_count'] > 0): ?>
<a href="#" onclick="document.getElementById('machines-<?= $l['id'] ?>').open = !document.getElementById('machines-<?= $l['id'] ?>').open; return false;"
style="color: var(--text); text-decoration: none; cursor: pointer;"
title="Cliquer pour voir les machines">
<strong><?= $l['machines_count'] ?></strong> / <?= $l['max_machines'] ?> ▾
</a>
<?php else: ?>
0 / <?= $l['max_machines'] ?>
<?php endif; ?>
</td>
<td>
<!-- READ-ONLY : channel actuel + badge BÊTA si actif. Tous
les éditeurs sont passés dans le modal pour éviter
l'encombrement de la cellule. -->
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
title="Channel actuellement attribué">
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
</code>
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
<?php endif; ?>
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
<?php if ($hasLock): ?>
<span class="badge badge-secondary" style="margin-left: 4px;" title="Section Settings → Avancés verrouillée par mot de passe">🔒</span>
<?php endif; ?>
</td>
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
<td style="text-align:right; white-space: nowrap;">
<details style="display: inline-block; margin: 0 4px;">
<summary class="btn btn-secondary">Prolonger</summary>
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="extend">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="date" name="new_until" required style="width: 150px;">
<button class="btn btn-primary" type="submit">OK</button>
</form>
</details>
<?php if ($l['machines_count'] > 0): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Libérer toutes les machines de cette license ?')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="reset_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit" title="Libère les slots machines occupés">Libérer machines</button>
</form>
<?php endif; ?>
<?php $modalId = 'edit-license-' . (int)$l['id']; ?>
<div class="row-actions">
<button type="button" class="btn btn-primary"
onclick="document.getElementById('<?= $modalId ?>').showModal()">
✎ Modifier
</button>
<?php if (!$isRevoked): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-danger" type="submit">Révoquer</button>
<button class="btn btn-danger" type="submit">Révoquer</button>
</form>
<?php else: ?>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="unrevoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit">Réactiver</button>
<button class="btn btn-secondary" type="submit">Réactiver</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php
// Modal d'édition (capturé en buffer, flush après </table>).
// Même pattern que versions.php : <dialog> dans <table> est
// foster-parented imprévisiblement → on émet hors de la table.
ob_start();
?>
<dialog id="<?= $modalId ?>" class="edit-modal">
<header>
<h3>License #<?= $l['id'] ?> — <?= htmlspecialchars($l['owner_name']) ?></h3>
<button type="button" class="close-x" aria-label="Fermer"
onclick="this.closest('dialog').close()">×</button>
</header>
<nav class="modal-tabs">
<button type="button" data-tab="prolong" class="active">Prolonger</button>
<button type="button" data-tab="slots">Slots</button>
<button type="button" data-tab="channel">Channel</button>
<button type="button" data-tab="beta">BÊTA</button>
<button type="button" data-tab="lock">Lock</button>
<button type="button" data-tab="contacts">Contacts</button>
<button type="button" data-tab="machines">Machines</button>
</nav>
<div class="modal-body">
<!-- TAB : Prolonger -->
<section data-tab="prolong">
<p class="section-intro">
Repousse la date d'expiration de l'entitlement.
Actuelle : <strong><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></strong>
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="extend">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Nouvelle date d'expiration</label>
<input type="date" name="new_until" required>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Prolonger</button>
</div>
</form>
</section>
<!-- TAB : Slots -->
<section data-tab="slots" hidden>
<p class="section-intro">
Nombre maximum de machines pouvant être activées simultanément.
Actuellement <strong><?= (int)$l['machines_count'] ?></strong>
active(s) sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
Pour réduire en-dessous du nombre actif, libère d'abord les slots
via l'onglet « Machines ».
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_max_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Slots max</label>
<input type="number" name="max_machines" min="1" max="100"
value="<?= (int)$l['max_machines'] ?>" required>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Slots</button>
</div>
</form>
</section>
<!-- TAB : Channel -->
<section data-tab="channel" hidden>
<p class="section-intro">
Quel manifest sert cette license ? « default » = manifest public.
Les channels privés (gérés sur la page <a href="channels.php">Channels</a>)
permettent de servir des versions ciblées (police, firefighter, …).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_channel">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Channel</label>
<select name="channel">
<?php foreach ($availableChannels as $value => $label):
$selected = ($l['channel'] ?? '') === $value ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($value) ?>" <?= $selected ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Channel</button>
</div>
</form>
</section>
<!-- TAB : BÊTA -->
<section data-tab="beta" hidden>
<p class="section-intro">
Si activé, le client voit en plus des versions stables toutes les
versions taggées <code>isBeta=true</code> dans le manifest. Sinon
elles lui sont invisibles (filtrées côté manifest signé).
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="toggle_betas">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>
<input type="checkbox" name="enabled" value="1"
<?= (int)($l['can_see_betas'] ?? 0) === 1 ? 'checked' : '' ?>>
Voir les versions BÊTA
</label>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer BÊTA</button>
</div>
</form>
</section>
<!-- TAB : Lock (settings password) -->
<section data-tab="lock" hidden>
<p class="section-intro">
Mot de passe qui verrouille la section Settings → Avancés du
launcher pour cette license. Vide = retirer le verrouillage.
Actuellement : <strong><?= $hasLock ? 'verrouillé 🔒' : 'pas verrouillé' ?></strong>
</p>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_settings_lock_password">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Mot de passe (≥4 caractères, ou vide pour retirer)</label>
<input type="password" name="settings_lock_password"
placeholder="<?= $hasLock ? 'Nouveau mot de passe (vide = retirer)' : 'Mot de passe' ?>"
minlength="0" maxlength="100"
autocomplete="new-password">
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Lock</button>
</div>
</form>
</section>
<!-- TAB : Contacts (emails de notification release) -->
<section data-tab="contacts" hidden>
<p class="section-intro">
Liste d'adresses email à notifier quand tu publies une nouvelle
version de PROSERVE accessible à cette license (matching channel +
entitlement). Format libre : une adresse par ligne, virgules, ou
point-virgules. Adresses invalides ignorées silencieusement.
La langue choisie ci-dessous est utilisée pour le contenu de l'email
(greeting + instructions) — les release notes elles-mêmes restent
toujours en anglais.
</p>
<?php
// Affichage en mode "1 par ligne" pour la lisibilité, peu importe
// comment c'est stocké en DB (CSV).
$currentEmails = Mailer::parseEmails($l['contact_emails'] ?? '');
$currentDisplay = implode("\n", $currentEmails);
$currentLang = trim((string)($l['language'] ?? ''));
$langOptions = [
'' => 'English (defaut)',
'fr' => 'Français',
'en' => 'English',
'es' => 'Español',
'de' => 'Deutsch',
'zh' => '中文 (Chinese)',
'th' => 'ภาษาไทย (Thai)',
'ar' => 'العربية (Arabic)',
];
?>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_contact_emails">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Langue de l'email</label>
<select name="language">
<?php foreach ($langOptions as $code => $label):
$sel = ($currentLang === $code) ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($code) ?>" <?= $sel ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
<p class="hint" style="margin: 4px 0 0;">
Langue dans laquelle l'email de notification sera rédigé.
Le contenu des release notes reste toujours en anglais.
</p>
</div>
<div class="field">
<label>Adresses email <span class="muted" style="font-weight: normal;">(<?= count($currentEmails) ?> actuellement)</span></label>
<textarea name="contact_emails" rows="6"
placeholder="ops@asterionvr.com&#10;jerome@client.com&#10;technicien@partner.fr"
style="font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px;"><?= htmlspecialchars($currentDisplay) ?></textarea>
<p class="hint" style="margin: 4px 0 0;">
Une par ligne, ou séparées par <code>,</code> / <code>;</code>.
Vider le champ = retirer toutes les notifications.
</p>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Contacts</button>
</div>
</form>
</section>
<!-- TAB : Machines -->
<section data-tab="machines" hidden>
<p class="section-intro">
<strong><?= (int)$l['machines_count'] ?></strong> machine(s) active(s)
sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
La gestion fine machine par machine se fait via le tableau dépliable
ci-dessous (clic sur « X / Y » dans la colonne Machines de la row).
</p>
<?php if ($l['machines_count'] > 0): ?>
<div class="field">
<label>Action en bloc</label>
<form method="post" style="display:inline"
onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nLes slots redeviendront disponibles pour de nouvelles activations.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="reset_machines">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-warning" type="submit">Libérer TOUTES les machines</button>
</form>
</div>
<?php else: ?>
<p class="muted" style="font-style: italic;">Aucune machine active sur cette license.</p>
<?php endif; ?>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Fermer</button>
</div>
</section>
</div>
</dialog>
<?php $GLOBALS['__editDialogs'] = ($GLOBALS['__editDialogs'] ?? '') . ob_get_clean(); ?>
<?php
// Sous-row dépliable : liste des machines avec remove individuel.
// Le <details> est piloté par le clic sur la cellule "X / Y" ci-dessus.
$machines = $machinesByLicense[(int)$l['id']] ?? [];
if (!empty($machines)):
?>
<tr>
<td colspan="8" style="padding: 0;">
<details id="machines-<?= $l['id'] ?>" style="margin: 0;">
<summary style="display: none;"></summary>
<div style="background: rgba(0,0,0,0.2); padding: 12px 16px; border-top: 1px solid var(--border);">
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;">
Machines actives sur la license #<?= $l['id'] ?> (<?= htmlspecialchars($l['owner_name']) ?>) :
</div>
<table style="width: 100%; font-size: 12px; margin: 0;">
<thead>
<tr>
<th style="width: 280px;">Machine ID (SHA-256)</th>
<th>Label</th>
<th style="width: 130px;">1ère activation</th>
<th style="width: 130px;">Dernière vue</th>
<th style="width: 110px; text-align:right;">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($machines as $m):
$mid = $m['machine_id'];
$isStale = strtotime($m['last_seen']) < time() - 86400 * 30;
?>
<tr>
<td>
<code title="<?= htmlspecialchars($mid) ?>"
style="font-family: 'Cascadia Code', Consolas, monospace;">
<?= htmlspecialchars(substr($mid, 0, 16)) ?>…<?= htmlspecialchars(substr($mid, -8)) ?>
</code>
</td>
<td><?= htmlspecialchars($m['machine_label'] ?? '—') ?></td>
<td class="muted"><?= date('d/m/Y H:i', strtotime($m['first_seen'])) ?></td>
<td class="muted">
<?= date('d/m/Y H:i', strtotime($m['last_seen'])) ?>
<?php if ($isStale): ?>
<span class="badge badge-warning" title="Pas vu depuis &gt; 30 jours, candidat à libération">stale</span>
<?php endif; ?>
</td>
<td style="text-align:right;">
<?php // Note : pas de "Déplacer vers une autre license" ici —
// ce serait une feature DB-only fragile (le launcher
// ré-activerait automatiquement la machine sur sa
// license d'origine au prochain validate puisque sa
// clé license stockée n'a pas changé). La migration
// d'une machine vers une autre license se fait côté
// launcher : Settings → License → entrer la nouvelle
// clé. ?>
<form method="post" style="display:inline"
onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="remove_machine">
<input type="hidden" name="license_id" value="<?= $l['id'] ?>">
<input type="hidden" name="machine_id" value="<?= htmlspecialchars($mid) ?>">
<button class="btn btn-danger" type="submit"
style="font-size: 11px; padding: 4px 10px;">
Libérer
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</details>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<?php if (empty($licenses)): ?>
<tr><td colspan="7" class="muted" style="text-align:center; padding: 32px;">Aucune license émise.</td></tr>
@@ -210,4 +722,70 @@ Layout::header('Licenses', 'licenses');
</tbody>
</table>
</div>
<?php // Dialogs émis hors de la <table> pour que showModal() fonctionne. ?>
<?= $GLOBALS['__editDialogs'] ?? '' ?>
<!-- Handler global pour les onglets des modals (délégation sur document). -->
<script>
(function () {
document.addEventListener('click', function (e) {
// 1. Tabs des modals d'édition license
var btn = e.target.closest('.modal-tabs button[data-tab]');
if (btn) {
var tabName = btn.dataset.tab;
var modal = btn.closest('dialog');
if (!modal) return;
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
b.classList.toggle('active', b === btn);
});
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
s.hidden = s.dataset.tab !== tabName;
});
return;
}
// 2. Boutons "📋 Copier" (clé license) — copie data-copy via Clipboard API
// + feedback visuel temporaire (1.5 s) sur le bouton lui-même.
var copyBtn = e.target.closest('[data-copy]');
if (copyBtn) {
var text = copyBtn.dataset.copy || '';
var original = copyBtn.textContent;
// Clipboard API moderne (Chrome 66+, Firefox 63+, Edge 79+, Safari 13.1+).
// Fallback execCommand pour les browsers très anciens.
var copyOk = function () {
copyBtn.textContent = '✓';
copyBtn.style.color = '#16A34A';
setTimeout(function () {
copyBtn.textContent = original;
copyBtn.style.color = '';
}, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(copyOk, function () {
// Échec d'autorisation (rare en HTTPS sur même origine) — fallback
fallbackCopy(text, copyOk);
});
} else {
fallbackCopy(text, copyOk);
}
}
});
// Fallback copie via textarea hidden + execCommand. Marche dans tous les
// browsers sans avoir besoin de la permission Clipboard API.
function fallbackCopy(text, onSuccess) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onSuccess(); }
catch (e) { /* silent */ }
document.body.removeChild(ta);
}
})();
</script>
<?php Layout::footer();

File diff suppressed because it is too large Load Diff

View File

@@ -40,4 +40,32 @@ return [
// php -r "echo password_hash('motdepasse_choisi', PASSWORD_DEFAULT);"
// puis colle le résultat ci-dessous.
'admin_password_hash' => '',
// === NOTIFICATIONS EMAIL (release announce) ===
// Utilisé par versions.php → action « ✉ Notifier » pour prévenir les
// contacts d'une license de la sortie d'une nouvelle version.
// Si SMTP non configuré, on retombe sur mail() natif PHP (qui marche sur
// OVH mutualisé via le relais SMTP local sans config additionnelle).
'notifications' => [
// Adresse "From" de l'email (doit être autorisée par OVH — typiquement
// une adresse hébergée sur ton domaine principal).
'from_address' => 'no-reply@asterionvr.com',
'from_name' => 'ASTERION VR — PROSERVE Launcher',
// Optionnel : Reply-To si tu veux que les réponses arrivent ailleurs
// (ex. boîte support partagée). Vide = pas de header Reply-To.
'reply_to' => '',
// Logo affiché en haut de l'email. Par défaut, l'image est EMBED INLINE
// via CID (Content-ID, pièce jointe MIME multipart/related). Avantage
// vs hotlink HTTP : Outlook (et la plupart des MUA) affiche l'image
// SANS demander la permission « Télécharger les images » au destinataire.
// Le path par défaut pointe vers admin/assets/asterion-logo.png — copié
// depuis le repo, tu peux remplacer le fichier librement (même URL servie).
'logo_path' => null, // null = auto-détect admin/assets/asterion-logo.png
// Fallback URL hotlink si tu préfères servir le logo depuis un CDN
// public au lieu du CID embed. UTILISÉ UNIQUEMENT si logo_path est null
// ou que le fichier est introuvable. Note : les MUA bloquent souvent
// les hotlinks par défaut → le destinataire devra cliquer « afficher
// les images » pour voir le logo. Préfère le CID embed (laisse logo_path).
'logo_url' => '',
],
];

View File

@@ -41,12 +41,17 @@ $route = trim((string)($_GET['route'] ?? ''), '/');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'GET' && ($route === '' || $route === 'manifest')) {
// Crypto requis : depuis v0.27.0 le manifest est re-signé à la volée
// selon le channel demandé (filtrage server-side).
require __DIR__ . '/lib/Crypto.php';
require __DIR__ . '/routes/Manifest.php';
\PSLauncher\Routes\Manifest::handle($config);
return;
}
if ($method === 'GET' && preg_match('#^releasenotes/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
// Release notes : accepte 'X.Y.Z' ou 'X.Y.Z.B' (4ᵉ digit = itération dev/test
// optionnelle) OU id stable 'v' + 8 hex (v0.27.1+).
if ($method === 'GET' && preg_match('#^releasenotes/(v[0-9a-f]{8}|[0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
require __DIR__ . '/routes/Releasenotes.php';
\PSLauncher\Routes\Releasenotes::handle($m[1]);
return;
@@ -60,7 +65,8 @@ if ($method === 'POST' && $route === 'license/validate') {
return;
}
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+)$#', $route, $m)) {
// Download URL : accepte X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
if ($method === 'GET' && preg_match('#^download-url/([0-9]+\.[0-9]+\.[0-9]+(?:\.[0-9]+)?)$#', $route, $m)) {
require __DIR__ . '/lib/Db.php';
require __DIR__ . '/lib/Crypto.php';
require __DIR__ . '/routes/DownloadUrl.php';

View File

@@ -19,20 +19,40 @@ final class DownloadUrl
{
public static function handle(array $config, string $version): void
{
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
// Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $version)) {
Response::error('invalid_version', 'Version invalide', 400);
}
// Auth via Authorization: Bearer <licenseKey>
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) {
// Fallback : certains hébergements masquent Authorization. On accepte aussi ?key=
$licenseKey = trim((string)($_GET['key'] ?? ''));
if ($licenseKey === '') {
Response::error('unauthorized', 'License key requise (Authorization: Bearer ... ou ?key=...)', 401);
// Apache OVH (et autres hébergements FastCGI) strippe parfois le header. On
// tente plusieurs sources :
// - $_SERVER['HTTP_AUTHORIZATION'] (cas standard)
// - $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] (mod_rewrite passthrough)
// - apache_request_headers() (présent quand mod_php)
// - getallheaders() (idem)
// - ?key=... (fallback explicite)
$authHeader = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '';
if ($authHeader === '' && function_exists('apache_request_headers')) {
$h = apache_request_headers();
$authHeader = $h['Authorization'] ?? $h['authorization'] ?? '';
}
} else {
if ($authHeader === '' && function_exists('getallheaders')) {
$h = getallheaders();
$authHeader = $h['Authorization'] ?? $h['authorization'] ?? '';
}
$licenseKey = '';
if (preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) {
$licenseKey = trim($m[1]);
} else {
$licenseKey = trim((string)($_GET['key'] ?? ''));
}
if ($licenseKey === '') {
Response::error('unauthorized',
'License key requise (Authorization: Bearer ... ou ?key=...)', 401);
}
$db = Db::get($config);
@@ -55,12 +75,36 @@ final class DownloadUrl
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
}
$manifest = json_decode(file_get_contents($manifestPath), true);
// Filename attendu, envoyé par le client (extrait de son propre manifest signé).
// Sert à disambiguer les manifestes multi-channels où plusieurs entrées
// partagent le même numéro de version (ex : proserve-firefighter-1.5.4.32 et
// proserve-full-1.5.4.32 sur v1.5.4.32). Sans ce filtre, le foreach ci-dessous
// retournait la 1re entrée matchant le numéro → l'URL signée pointait vers le
// MAUVAIS ZIP, et le client détectait le mismatch filename manifest vs signé
// et abortait (garde-fou côté MainViewModel.InstallVersionAsync). Optionnel
// pour rétro-compat avec les vieux clients (v1.0.4-) qui ne l'envoient pas ;
// dans ce cas la 1re entrée gagne, comme avant.
$expectedFilename = trim((string)($_GET['filename'] ?? ''));
// Whitelist défensive — même règle que la validation du filename lu du manifest
// plus bas dans cette route. Bloque path traversal via query param.
if ($expectedFilename !== '' && !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $expectedFilename)) {
Response::error('invalid_filename', "Query filename invalide : '{$expectedFilename}'", 400);
}
$entry = null;
foreach ($manifest['versions'] ?? [] as $v) {
if (($v['version'] ?? '') === $version) { $entry = $v; break; }
if (($v['version'] ?? '') !== $version) continue;
if ($expectedFilename !== '') {
$entryFilename = basename(parse_url((string)($v['download']['url'] ?? ''), PHP_URL_PATH) ?: '');
if ($entryFilename !== $expectedFilename) continue;
}
$entry = $v;
break;
}
if (!$entry) {
Response::error('version_not_found', "Version {$version} absente du manifest", 404);
$suffix = $expectedFilename !== '' ? " (filename attendu : {$expectedFilename})" : '';
Response::error('version_not_found', "Version {$version} absente du manifest{$suffix}", 404);
}
// Vérif droits téléchargement
@@ -76,10 +120,55 @@ final class DownloadUrl
]);
}
// Extrait le NOM DE FICHIER depuis l'URL du manifest (= source de vérité).
// Avant : on utilisait un template hardcodé `proserve-{version}.zip`,
// ce qui ignorait toute personnalisation du nom (ex. opérateur qui rename
// en `proserve-full-1.5.4.zip` pour buster un cache CDN). Conséquence :
// l'endpoint retournait une URL signée pointant vers un fichier inexistant
// → 404 systématique côté client sans aucune indication serveur.
// Maintenant : on lit `download.url` du manifest, on extrait le filename
// via parse_url + basename, on vérifie qu'il existe physiquement dans
// /builds/, et seulement après on signe.
$manifestUrl = $entry['download']['url'] ?? '';
if ($manifestUrl === '') {
Response::error('manifest_incomplete',
"L'entrée manifest pour v{$version} est sans download.url", 500);
}
$parsedUrl = parse_url($manifestUrl);
$urlPath = $parsedUrl['path'] ?? '';
$filename = basename($urlPath);
// Whitelist défensive sur le filename : caractères safe + suffixe .zip.
// Évite path traversal et autres injections via un manifest corrompu.
if ($filename === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $filename)) {
Response::error('manifest_invalid_filename',
"Nom de fichier invalide extrait du manifest pour v{$version} : '{$filename}'", 500);
}
// Vérifie que le fichier existe physiquement avant de signer une URL morte.
// Cas concret de bug remonté côté client : l'opérateur rename le ZIP dans
// /builds/ mais oublie de mettre à jour versions.json (ou inverse). Au
// lieu d'envoyer le client en 404 silencieux, on retourne une erreur
// serveur claire qui apparaît dans les logs PHP + client.
$buildsDir = dirname(__DIR__, 2) . '/builds';
$physicalPath = $buildsDir . '/' . $filename;
if (!is_file($physicalPath)) {
Response::error('file_missing',
"Le fichier ZIP « {$filename} » référencé par le manifest pour v{$version} est absent du dossier /builds/. Vérifie que le manifest et le filesystem sont synchros.",
500,
[
'manifestUrl' => $manifestUrl,
'expectedPath' => $physicalPath,
]);
}
// Génère l'URL HMAC-signée
// TTL : 6 h. Compromis entre :
// - sécurité (limite la fenêtre de replay si une URL fuit)
// - utilisabilité (un user en ADSL 8 Mbps mettra ~4 h pour DL 14 Go)
// Pour une connexion plus lente, le client sait auto-refresher l'URL
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
$baseUrl = rtrim($config['base_url'], '/');
$relPath = '/builds/proserve-' . $version . '.zip';
$exp = time() + 3600; // 1 h
$relPath = '/builds/' . $filename;
$exp = time() + 21600; // 6 h
$secret = $config['hmac_secret'] ?? '';
if ($secret === '') {
Response::error('config_error', 'hmac_secret non configuré', 500);

View File

@@ -3,21 +3,156 @@ declare(strict_types=1);
namespace PSLauncher\Routes;
use PSLauncher\Crypto;
use PSLauncher\Response;
/**
* Endpoint manifest public. Reçoit ?channel=X depuis le launcher (channel
* provenant de la license signée Ed25519). On filtre les versions pour ne
* renvoyer que celles taggées sur ce channel ou sur "default", et on re-signe
* le manifest filtré à la volée avec la clé privée Ed25519.
*
* Sémantique additive : un client sur channel "police" voit "default" + "police".
* Pour rendre une version invisible au public, ne PAS la tagger "default".
*/
final class Manifest
{
private const CHANNEL_REGEX = '/^[a-z0-9_-]{1,64}$/';
public static function handle(array $config): void
{
$file = dirname(__DIR__, 2) . '/manifest/versions.json';
if (!is_file($file)) {
$manifestPath = dirname(__DIR__, 2) . '/manifest/versions.json';
if (!is_file($manifestPath)) {
Response::error('manifest_missing', 'versions.json not found on server', 404);
}
// Cache court pour absorber les pics, doit être revalidé pour ne pas masquer une nouvelle version
$raw = file_get_contents($manifestPath);
$manifest = json_decode((string)$raw, true);
if (!is_array($manifest)) {
Response::error('manifest_invalid', 'versions.json is not valid JSON', 500);
}
// Channel demandé par le client. Whitelist regex anti-injection. Un
// channel inconnu / mal formé est traité comme NULL → le client voit
// uniquement les versions "default".
$clientChannel = $_GET['channel'] ?? null;
if (!is_string($clientChannel) || $clientChannel === '' || !preg_match(self::CHANNEL_REGEX, $clientChannel)) {
$clientChannel = null;
}
// Multi-channel opt-in : les clients v1.0.10+ savent afficher plusieurs
// entries au même numéro de version (channels distincts). Ils passent
// ?multiChannel=1 pour signaler la capabilité. Les clients v1.0.9- (et
// avant refactor row-by-Id) crasheraient sur ToDictionary(v.Version) →
// le serveur reste en mode dédup pour eux (comportement historique).
$multiChannel = !empty($_GET['multiChannel']);
$manifest['versions'] = self::filterVersions(
$manifest['versions'] ?? [],
$clientChannel,
$multiChannel
);
// Re-signature à la volée. La clé privée Ed25519 est dans config.php
// (sert déjà aux licenses), accessible uniquement depuis le serveur.
// Coût : ~1ms d'Ed25519 par requête, négligeable vs le réseau.
$sk = $config['ed25519']['private_key_hex'] ?? '';
if ($sk !== '') {
unset($manifest['signature']); // important : canonical sans la signature
$manifest['signature'] = Crypto::signEd25519(Crypto::canonicalJson($manifest), $sk);
} else {
unset($manifest['signature']); // pas de clé = pas de signature, le client échouera la vérif
}
$body = json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// Cache court pour absorber les pics. ETag inclut le channel pour que
// les caches HTTP intermédiaires séparent bien les vues par client.
header('Cache-Control: public, max-age=300, must-revalidate');
header('ETag: "' . md5_file($file) . '"');
header('ETag: "' . md5(($clientChannel ?? '') . '|' . md5_file($manifestPath)) . '"');
header('Content-Type: application/json; charset=utf-8');
header('Content-Length: ' . filesize($file));
readfile($file);
header('Content-Length: ' . strlen((string)$body));
echo $body;
}
/**
* Filtre + dédupe par numéro de version.
*
* Étape 1 — filter : on garde les versions visibles par ce client. Un
* client sur channel X voit les entries taggées 'default' OU 'X'.
*
* Étape 2 — dedupe : pour chaque numéro de version qui apparaît plusieurs
* fois (cas v1.5.3 default + v1.5.3 firefighter), on ne renvoie qu'UNE
* seule entry — la plus spécifique au client. Priorité : l'entry taggée
* avec le channel propre du client gagne ; à défaut, l'entry default
* sert de fallback. Sans cette dédup côté serveur, le launcher recevait
* deux entries v1.5.3 et son `ToDictionary(v => v.Version)` collisionnait
* silencieusement, ce qui faisait apparaître dans l'UI le mauvais ZIP
* pour un user firefighter.
*
* @param array<int, array<string,mixed>> $versions
* @return list<array<string,mixed>>
*/
private static function filterVersions(array $versions, ?string $clientChannel, bool $multiChannel = false): array
{
// Étape 1 : filter visible par ce client
$visible = [];
foreach ($versions as $v) {
$channels = (isset($v['channels']) && is_array($v['channels']) && !empty($v['channels']))
? $v['channels']
: ['default'];
$isPublic = in_array('default', $channels, true);
$matchesUser = $clientChannel !== null && in_array($clientChannel, $channels, true);
if ($isPublic || $matchesUser) {
$visible[] = ['entry' => $v, 'channels' => $channels];
}
}
// Client v1.0.10+ : bypass la dédup, retourne toutes les entries visibles.
// Le client sait maintenant afficher plusieurs rows au même numéro (keyage
// par entryId côté RebuildList) et bloque les collisions d'install côté
// guard. Le badge channel n'apparaît que si plusieurs entries partagent
// le numéro (v1.0.9+) → UX propre.
if ($multiChannel) {
return array_map(fn($item) => $item['entry'], $visible);
}
// Étape 2 (clients v1.0.9-) : group by version, pick most specific per
// group. Comportement historique pour ne pas crasher les vieux clients
// qui font ToDictionary(v.Version) et exploseraient sur des duplicates.
$byVersion = [];
foreach ($visible as $item) {
$key = (string)($item['entry']['version'] ?? '?');
$byVersion[$key][] = $item;
}
$result = [];
foreach ($byVersion as $items) {
if (count($items) === 1) {
$result[] = $items[0]['entry'];
continue;
}
// Plusieurs entries pour ce numéro : on cherche d'abord une qui
// matche le channel spécifique du client (firefighter, police…),
// sinon on retombe sur l'entry default.
$specific = null;
$defaultEntry = null;
foreach ($items as $item) {
if ($clientChannel !== null
&& $specific === null
&& in_array($clientChannel, $item['channels'], true)) {
$specific = $item;
}
if ($defaultEntry === null && in_array('default', $item['channels'], true)) {
$defaultEntry = $item;
}
}
$picked = $specific ?? $defaultEntry ?? $items[0];
$result[] = $picked['entry'];
}
return array_values($result);
}
}

View File

@@ -5,16 +5,28 @@ namespace PSLauncher\Routes;
use PSLauncher\Response;
/**
* Release notes endpoint. Accepte deux formats d'identifiant :
* - id stable d'entrée : 'v' + 8 hex chars (format depuis v0.27.1, supporte
* plusieurs entries de même version)
* - numéro de version legacy : X.Y.Z (compat ascendante avec les notes
* créées avant v0.27.1)
*/
final class Releasenotes
{
public static function handle(string $version): void
public static function handle(string $key): void
{
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) {
Response::error('invalid_version', 'Bad version format', 400);
$isEntryId = (bool)preg_match('/^v[0-9a-f]{8}$/', $key);
// X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
$isVersion = (bool)preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $key);
if (!$isEntryId && !$isVersion) {
Response::error('invalid_id', 'Bad release-notes identifier', 400);
}
$file = dirname(__DIR__, 2) . "/releasenotes/{$version}.md";
$file = dirname(__DIR__, 2) . "/releasenotes/{$key}.md";
if (!is_file($file)) {
Response::error('not_found', "Release notes for {$version} not found", 404);
Response::error('not_found', "Release notes for {$key} not found", 404);
}
header('Cache-Control: public, max-age=3600, must-revalidate');
header('Content-Type: text/markdown; charset=utf-8');

View File

@@ -30,7 +30,7 @@ final class ValidateLicense
$stmt = $db->prepare(
'SELECT id, license_key, owner_name, issued_at, download_entitlement_until,
max_machines, revoked_at
max_machines, channel, can_see_betas, settings_lock_password_hash, revoked_at
FROM licenses WHERE license_key = ? LIMIT 1'
);
$stmt->execute([$licenseKey]);
@@ -82,6 +82,23 @@ final class ValidateLicense
$serverTime = (new \DateTimeImmutable('now', $utc))->format($fmt);
$expired = strtotime($lic['download_entitlement_until']) < time();
// ATTENTION : ordre des clés CRITIQUE pour la signature Ed25519.
// Crypto::canonicalJson NE TRIE PAS — il prend l'ordre tel quel. Le client
// C# (LicenseService.CanonicalBytesFor) reconstruit le même dictionnaire
// dans le même ordre. Toute modif ici doit être miroir côté client.
// channel : NULL en DB → null JSON (visible dans le canonical comme "channel":null).
// can_see_betas : 0/1 en DB → bool JSON.
$channel = isset($lic['channel']) && $lic['channel'] !== null && $lic['channel'] !== ''
? (string)$lic['channel']
: null;
$canSeeBetas = (bool)($lic['can_see_betas'] ?? 0);
// SettingsLockPasswordHash : présent depuis migration 003, NULL = pas de
// verrouillage. On renvoie tel quel (null ou hex 64). Le launcher applique
// côté ISettingsLockService.SetPasswordHash après validation signée.
$settingsLockHash = isset($lic['settings_lock_password_hash']) && $lic['settings_lock_password_hash'] !== ''
? (string)$lic['settings_lock_password_hash']
: null;
$payload = [
'status' => $expired ? 'expired' : 'valid',
'licenseId' => 'lic_' . $lic['id'],
@@ -89,6 +106,9 @@ final class ValidateLicense
'issuedAt' => $issuedAt,
'downloadEntitlementUntil' => $entUntil,
'maxMachines' => (int)$lic['max_machines'],
'channel' => $channel,
'canSeeBetas' => $canSeeBetas,
'settingsLockPasswordHash' => $settingsLockHash,
'serverTime' => $serverTime,
];

9
server/builds/.htaccess Normal file
View File

@@ -0,0 +1,9 @@
# Tout accès direct à un .zip passe d'abord par le gardien gate.php qui vérifie
# la signature HMAC + l'expiration. Si OK, le gardien sert le fichier (avec
# support Range pour la reprise). Sinon, 403.
RewriteEngine On
RewriteBase /PS_Launcher/builds/
RewriteCond %{REQUEST_FILENAME} -f
RewriteCond %{REQUEST_URI} \.zip$
RewriteRule ^(.*)$ gate.php?file=$1 [QSA,L]

93
server/builds/gate.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
/**
* Gardien des téléchargements de builds. Sert un .zip seulement si l'URL est
* accompagnée d'une signature HMAC valide non expirée, signée par la même
* clé que /api/download-url/{version}. Supporte les Range: requests pour la
* reprise (dégradé : on utilise readfile + fseek manuellement).
*
* Pour les hébergements où on veut désactiver la protection (tests internes), il
* suffit de retirer le RewriteRule du .htaccess voisin — Apache servira alors
* directement le fichier statique avec son support natif des Range.
*/
declare(strict_types=1);
ini_set('display_errors', '0');
set_time_limit(0);
ignore_user_abort(true);
require __DIR__ . '/../api/lib/Crypto.php';
$config = require __DIR__ . '/../api/config.php';
$secret = $config['hmac_secret'] ?? '';
function deny(string $reason, int $code = 403): never
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
echo "Forbidden: $reason\n";
exit;
}
$file = trim((string)($_GET['file'] ?? ''), '/');
if ($file === '' || !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $file)) deny('invalid file');
$path = __DIR__ . '/' . $file;
if (!is_file($path)) deny('not found', 404);
$exp = (int)($_GET['exp'] ?? 0);
$lic = (string)($_GET['lic'] ?? '');
$sig = (string)($_GET['sig'] ?? '');
if ($exp <= 0 || $lic === '' || $sig === '') deny('missing params');
if ($exp < time()) deny('expired');
if ($secret === '') deny('server config error', 500);
$relPath = '/builds/' . $file;
$expected = \PSLauncher\Crypto::hmacHex($relPath . '|' . $exp . '|' . $lic, $secret);
if (!hash_equals($expected, $sig)) deny('bad signature');
// Sert le fichier avec support Range
$size = filesize($path);
$mtime = filemtime($path) ?: 0;
$start = 0;
$end = $size - 1;
header('Content-Type: application/zip');
header('Accept-Ranges: bytes');
// ⚠️ NE PAS utiliser md5_file($path) ici : sur un fichier 14 Go, ça relit
// l'intégralité du ZIP à chaque requête (~30 s-5 min selon disque). Avec
// 8 segments parallèles + HEAD probe, ça multipliait par 9 le temps avant
// le premier byte. On utilise un ETag dérivé size+mtime, équivalent à ce
// qu'Apache fait nativement pour les fichiers statiques. Change quand on
// upload un nouveau ZIP, donc cache invalidation correcte.
header('ETag: "' . dechex($size) . '-' . dechex($mtime) . '"');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
header('Cache-Control: private, max-age=3600');
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d+)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
$start = (int)$m[1];
if ($m[2] !== '') $end = min((int)$m[2], $size - 1);
if ($start > $end || $start >= $size) {
http_response_code(416);
header("Content-Range: bytes */$size");
exit;
}
http_response_code(206);
header("Content-Range: bytes $start-$end/$size");
}
$length = $end - $start + 1;
header('Content-Length: ' . $length);
$fp = fopen($path, 'rb');
fseek($fp, $start);
$bufSize = 1 << 20; // 1 MiB
$remaining = $length;
while ($remaining > 0 && !feof($fp) && !connection_aborted()) {
$chunk = fread($fp, (int)min($bufSize, $remaining));
if ($chunk === false) break;
echo $chunk;
@ob_flush();
@flush();
$remaining -= strlen($chunk);
}
fclose($fp);

View File

@@ -9,7 +9,7 @@
"version": "1.4.6",
"releasedAt": "2026-04-29T10:00:00Z",
"executable": "PROSERVE_UE_5_5.exe",
"installFolderTemplate": "Proserve v{version}",
"installFolderTemplate": "PROSERVE v{version}",
"download": {
"url": "https://asterionvr.com/PS_Launcher/builds/proserve-1.4.6.zip",
"sizeBytes": 0,
@@ -23,7 +23,7 @@
"version": "1.4.5",
"releasedAt": "2026-04-15T10:00:00Z",
"executable": "PROSERVE_UE_5_5.exe",
"installFolderTemplate": "Proserve v{version}",
"installFolderTemplate": "PROSERVE v{version}",
"download": {
"url": "https://asterionvr.com/PS_Launcher/builds/proserve-1.4.5.zip",
"sizeBytes": 0,

View File

@@ -0,0 +1,19 @@
-- PS_Launcher schema v2
-- Ajoute deux flags par-license :
-- * channel : nom du manifest à servir (NULL = default = manifest/versions.json)
-- * can_see_betas : autorise la license à voir les versions taggées isBeta=true
--
-- À jouer après 001_init.sql.
--
-- IDEMPOTENCE : les ALTER ci-dessous throw une exception sur OVH si la colonne /
-- l'index existe déjà. migrate.php attrape ces erreurs spécifiques ("Duplicate
-- column", "Duplicate key name") et continue. Tu peux rejouer le script sans
-- risque sur n'importe quelle DB (vide, partiellement migrée, complètement).
ALTER TABLE licenses ADD COLUMN channel VARCHAR(64) NULL AFTER max_machines;
ALTER TABLE licenses ADD COLUMN can_see_betas TINYINT(1) NOT NULL DEFAULT 0 AFTER channel;
ALTER TABLE licenses ADD INDEX idx_channel (channel);
-- Note : pas de migration de data. Les licenses existantes gardent
-- channel = NULL (= manifest default) et can_see_betas = 0 (= pas d'accès aux betas).
-- L'admin attribue manuellement les flags via la page licenses.php.

View File

@@ -0,0 +1,15 @@
-- PS_Launcher schema v3
-- Ajoute settings_lock_password_hash sur la table licenses : permet à l'admin
-- de définir un mot de passe par-license qui verrouille la section "Avancés"
-- des Settings côté launcher. Le hash SHA-256 (hex lowercase) est renvoyé au
-- launcher dans la réponse signée de /license/validate, donc l'intégrité
-- bénéficie de la signature Ed25519 existante.
--
-- À jouer après 001_init.sql et 002_channel_betas.sql.
-- migrate.php attrape "Duplicate column" comme idempotent.
ALTER TABLE licenses ADD COLUMN settings_lock_password_hash CHAR(64) NULL AFTER can_see_betas;
-- Note : les licenses existantes ont settings_lock_password_hash = NULL → pas
-- de verrouillage côté launcher → comportement identique à avant. L'admin
-- attribue manuellement via le bouton 🔒 Lock dans licenses.php.

View File

@@ -0,0 +1,18 @@
-- PS_Launcher schema v4
-- Ajoute contact_emails sur la table licenses : liste d'adresses séparées
-- par virgules ou retours-ligne (parsing tolérant côté admin), à utiliser
-- pour notifier les clients quand une nouvelle version PROSERVE est publiée.
--
-- Format de stockage : TEXT brut (pas JSON) — l'admin saisit librement, le
-- backend split sur [,;\n\r\s] et filtre par regex email avant l'envoi.
-- Pourquoi pas une table normalisée license_contacts(license_id, email) :
-- overkill pour une feature de notification opportuniste sans relations.
--
-- À jouer après 001_init.sql, 002_channel_betas.sql, 003_settings_lock.sql.
-- migrate.php attrape "Duplicate column" comme idempotent.
ALTER TABLE licenses ADD COLUMN contact_emails TEXT NULL AFTER settings_lock_password_hash;
-- Note : les licenses existantes ont contact_emails = NULL → aucun email
-- à notifier → comportement identique à avant. L'admin doit explicitement
-- attribuer les contacts via le modal d'édition license, onglet « Contacts ».

View File

@@ -0,0 +1,11 @@
-- PS_Launcher schema v5
-- Ajoute language sur la table licenses : code de langue 2-letter (fr/en/zh/th/ar)
-- utilisé pour localiser les emails de notification de release. NULL = English
-- (defaut conservateur pour les licenses existantes qui n'ont pas été éditées).
-- Le contenu de la release note elle-même reste TOUJOURS en anglais — seul le
-- texte d'accompagnement (greeting, instructions, CTAs) est localisé.
--
-- À jouer après 004_license_contact_emails.sql.
-- migrate.php attrape "Duplicate column" comme idempotent.
ALTER TABLE licenses ADD COLUMN language VARCHAR(8) NULL AFTER contact_emails;

View File

@@ -0,0 +1,32 @@
# Proserve v1.5.3
*Release date: May 3, 2026*
## What's new
- **New MM CQB scenario** (close-quarters combat in an urban environment)
- **Persistent weapon selection** for the entire PROSERVE session — the default weapon stored in the database is only restored when PROSERVE is restarted
## Improvements
- Refreshed **statistics icon** in the UI
- **Quick calibration** in the Calibration Scene is now restricted to users equipped with a VR controller (works around a bug specific to Firefighter mode)
## Bug fixes
- Fixed a bug in the **instructor view** affecting the player description
- Miscellaneous stability fixes
## Database migration
This version automatically applies 3 database migrations through the launcher:
- `0001_Init.sql` — baseline schema (no-op if the database already exists)
- `0002_SessionType_ps_1.4.5.sql` — adds the **LongRange** session type and renames type 6 to **Rescue**
- `0003_UserSize_ps_1.5.0.sql` — adds the `size` column (in cm) to the `users` table
No manual action required. If XAMPP is offline at install time, you can replay the migrations later via **Settings → Advanced → Database → Replay migrations**.
## Notes
- Deployment of the local reporting tool (`/ProserveReport/`) is synchronized with PROSERVE: a timestamped backup of the previous version is kept under `C:\xampp\htdocs\` and a revert is available via **Settings → Advanced → Report Tool**.

View File

@@ -0,0 +1,51 @@
# PROSERVE v1.5.4
Built on **Unreal Engine 5.7** — major visual fidelity and runtime
performance improvements across all scenarios.
This release is packed with new tooling and a major rework of our AI
systems. Here's what's new:
## ✨ New features
- **PROSERVE Editor (v0.5 Alpha)** — first integrated editor for
building and tweaking scenarios directly inside PROSERVE.
*Still alpha — feedback welcome.*
- **AI Autonomous Agent — full rework** — smarter decision-making,
more believable behaviors, smoother pathfinding and reactions.
- **Conversational AI Agent (Alpha)** — talk to NPCs in natural
language. First iteration, expect rapid improvements over the
upcoming releases.
- **PS_Launcher** — new companion tool that simplifies version
updates and the launch of PROSERVE on each workstation. Auto-detects
new releases, handles install + redists, and surfaces stats and
documentation in one place.
Download: <https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe>
- **PS_Report** — view, export, and print your mission results and
trainee statistics directly from the launcher.
## 🚀 Gameplay improvements
- **Aiming** — reduced reaction time and improved shooting precision
across all firearms.
- **Character gaze** — characters now realistically track the
trainee and points of interest, adding presence and immersion to
every interaction.
- **Hose behavior (Firefighter edition)** — reworked physics and
grip feel for the firehose; more predictable, less jittery, more
satisfying to handle.
## 🌍 Localization
- **Thai language** added — full UI translation.
---
*As always, your feedback is invaluable. If you spot any issue or
have suggestions, reach out to your ASTERION VR account manager.*

View File

@@ -13,6 +13,7 @@ final class SignManifest
public string $manifestPath;
public string $buildsDir;
public ?string $configPath;
public string $hashCachePath;
/** @var string[] */
public array $log = [];
@@ -21,19 +22,174 @@ final class SignManifest
$this->manifestPath = $rootDir . '/manifest/versions.json';
$this->buildsDir = $rootDir . '/builds';
$this->configPath = $rootDir . '/api/config.php';
// Cache des hashs déjà calculés. Indexé par chemin absolu du ZIP, contient
// { size, mtime, sha256 } ; on recalcule uniquement si size ou mtime ont changé.
// Vit hors du répertoire web servi (sécurité : un utilisateur n'a aucune raison
// de pouvoir lire le cache).
$this->hashCachePath = $rootDir . '/manifest/.hashcache.json';
}
private function out(string $line): void { $this->log[] = $line; }
/**
* Recompute sizes / sha256 for every version that has its ZIP uploaded,
* bump 'latest' to the highest signed version, then sign the manifest
* with the Ed25519 private key from config.php (if available).
* Lit le cache des hashs précalculés. Retourne un dictionnaire
* [path => ['size' => int, 'mtime' => int, 'sha256' => string]].
* @return array<string, array{size:int,mtime:int,sha256:string}>
*/
private function loadHashCache(): array
{
if (!is_file($this->hashCachePath)) return [];
$raw = @file_get_contents($this->hashCachePath);
if ($raw === false) return [];
$j = json_decode($raw, true);
return is_array($j) ? $j : [];
}
/** @param array<string, array{size:int,mtime:int,sha256:string}> $cache */
private function saveHashCache(array $cache): void
{
@file_put_contents($this->hashCachePath, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
}
/**
* Calcule (ou retourne depuis le cache) le SHA-256 d'un fichier.
* Le cache est invalidé dès que la taille OU mtime du fichier a changé,
* ce qui permet de garder le cache à jour sans intervention manuelle.
*
* IMPORTANT : si <code>$force</code> est true, on IGNORE le cache et on
* recalcule (puis on rafraîchit le cache). Sans ça, le cache interne
* supplantait silencieusement le `--force` du caller — bug subtil parce
* que la couche outer de `run()` avait déjà skip le cache (sha REPLACE
* ou force=true), mais ce helper retombait sur sa propre cache lookup
* (clé = realpath) → retournait instantanément l'ancien hash sans
* recalculer, même après que l'opérateur ait re-uploadé le ZIP via
* SFTP avec mtime préservé.
*
* @param array<string, array{size:int,mtime:int,sha256:string}> $cache
* @return array{sha256:string, fromCache:bool, durationMs:int}
*/
private function getOrComputeSha256(string $path, array &$cache, bool $force = false): array
{
$size = filesize($path) ?: 0;
$mtime = filemtime($path) ?: 0;
$key = realpath($path) ?: $path;
if (!$force
&& isset($cache[$key])
&& ($cache[$key]['size'] ?? -1) === $size
&& ($cache[$key]['mtime'] ?? -1) === $mtime
&& !empty($cache[$key]['sha256'])) {
return ['sha256' => $cache[$key]['sha256'], 'fromCache' => true, 'durationMs' => 0];
}
$start = microtime(true);
// Streamed hash en chunks 16 Mo. Deux bénéfices vs hash_file() atomique :
// 1. On peut envoyer un heartbeat (flush()) entre chunks pour que le
// proxy Apache/OVH ne timeout pas la requête pendant les ~5-10 min
// que prend un SHA-256 sur un ZIP de 14 Go via SAN mutualisé.
// 2. On peut relever set_time_limit() à chaque chunk (fenêtre glissante)
// au lieu de faire un unique set_time_limit(0) à la caller.
// Mémoire : hash_update_stream() ne buffere pas — c'est du streaming pur.
$fp = @fopen($path, 'rb');
if ($fp === false) {
return ['sha256' => '', 'fromCache' => false, 'durationMs' => 0];
}
try {
$ctx = hash_init('sha256');
// 16 Mo = compromis entre nombre d'appels PHP et pression CPU par read()
$chunkBytes = 16 * 1024 * 1024;
while (!feof($fp)) {
hash_update_stream($ctx, $fp, $chunkBytes);
// Fenêtre glissante : autorise ~5 min de plus avant que PHP ne
// timeout. Sur un fichier de 14 Go / chunks 16 Mo = ~900 itérations,
// donc si un chunk prend >5 min c'est vraiment que le disque est HS.
@set_time_limit(300);
// Heartbeat côté front — évite Apache RequestTimeout / OVH proxy
// timeout sur les grosses requêtes. Silencieux si output buffering
// est actif (pas fatal).
@ob_flush();
@flush();
}
$sha = hash_final($ctx);
} finally {
fclose($fp);
}
$duration = (int)((microtime(true) - $start) * 1000);
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha];
return ['sha256' => $sha, 'fromCache' => false, 'durationMs' => $duration];
}
/**
* Recompute sizes / sha256 selon le scope, puis re-signe le manifest avec Ed25519.
*
* Stratégie de hash :
* - Cache disque {$buildsDir}/.hashcache.json indexé par chemin → (size, mtime, sha256)
* - On re-hash seulement si la taille ou mtime du ZIP a changé (= upload de nouveau build)
* - Si le manifest contient déjà un sha256 valide ET que size+mtime n'ont pas changé,
* on évite carrément l'appel à hash_file()
* - Si une version a `download.hashAlgorithm = "none"` ou `download.skipHash = true`
* dans le manifest, on ne calcule pas le hash (sha256 reste à null/empty)
* - $force = true pour forcer un recalcul intégral (utile en cas de doute)
*
* Sur un mutualisé OVH avec 5 versions × 14 Go : avant ~25 min, après ~quelques secondes
* pour les versions inchangées + ~1 min par nouvelle version uploadée.
*
* @param string $scope 'all' (défaut) | 'versions' (ne touche que les Proserve) | 'launcher'
* @param bool $force Si true, ignore le cache et recalcule tous les hashs
* @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
* versions[] (les autres restent inchangées). Le scope 'launcher'
* est ignoré dans ce cas. ATTENTION : si plusieurs entrées
* partagent le même numéro de version (channels firefighter
* vs full sur 1.5.4.32 p.ex.), TOUTES sont hashées — d'où
* $onlyEntryId ci-dessous pour cibler une seule ligne.
* @param ?string $onlyEntryId Si non null, ne touche QUE l'entrée avec cet id (généré par
* generate_entry_id() au backoffice). Prend le pas sur
* $onlyVersion. Utilisé par le bouton « 🔁 Hash » d'une
* ligne isolée pour éviter de re-hasher les autres channels
* qui partagent le même numéro de version (2 × 14 Go dans une
* requête HTTP → risque de timeout front OVH).
* @return array{ok:bool, log:string[]}
*/
public function run(): array
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null, ?string $onlyEntryId = null): array
{
// Sur OVH mutualisé, un SHA-256 d'un ZIP de 14 Go peut prendre 5-10 min via
// le SAN partagé. Le max_execution_time par défaut (30-60s) tue le process
// → Apache retourne 500 Internal Server Error avec le boilerplate
// postmaster@… — c'est le mode d'échec principal du bouton « Hash » au
// backoffice. On désactive la limite ici (couvre AUSSI les callers CLI et
// cron, pas seulement l'admin web). ignore_user_abort évite qu'un refresh
// ou une fermeture d'onglet côté opérateur interrompe un hash en cours.
@set_time_limit(0);
@ignore_user_abort(true);
// Capture toutes les erreurs PHP (warnings + fatals + exceptions non-catchées)
// dans un fichier accessible via SFTP, à côté du manifest. Sur OVH mutualisé
// les logs Apache ne sont accessibles que via le manager web — pas pratique
// pour un diagnostic rapide de 500. Ce fichier permet à l'opérateur de le
// grep post-clic sans passer par le manager.
$errorLogPath = dirname($this->manifestPath) . '/.signmanifest-error.log';
@ini_set('log_errors', '1');
@ini_set('error_log', $errorLogPath);
@error_reporting(E_ALL);
$ts = date('Y-m-d H:i:s');
@file_put_contents($errorLogPath,
"[{$ts}] --- SignManifest::run(scope={$scope}, force=" . ($force?'1':'0')
. ", onlyVersion=" . ($onlyVersion ?? 'null')
. ", onlyEntryId=" . ($onlyEntryId ?? 'null') . ") ---\n",
FILE_APPEND);
// Fatal errors → capturés par un shutdown handler. Sinon Apache renvoie
// juste 500 sans qu'on sache ce qui a claqué.
register_shutdown_function(function () use ($errorLogPath) {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] FATAL " . $err['type']
. " : {$err['message']} @ {$err['file']}:{$err['line']}\n",
FILE_APPEND);
}
});
if (!is_file($this->manifestPath)) {
$this->out("Manifest introuvable : {$this->manifestPath}");
return ['ok' => false, 'log' => $this->log];
@@ -45,9 +201,33 @@ final class SignManifest
return ['ok' => false, 'log' => $this->log];
}
$doVersions = ($scope === 'all' || $scope === 'versions');
$doLauncher = ($scope === 'all' || $scope === 'launcher');
if ($onlyVersion !== null || $onlyEntryId !== null) {
// Mode "hash une seule entrée / version" : on ne touche pas au launcher,
// et on ne hash que ce qui correspond au filtre demandé dans versions[].
$doLauncher = false;
$doVersions = true;
}
// Charge le cache des hashs (indexé par chemin réel du ZIP)
$cache = $this->loadHashCache();
$cacheChanged = false;
$hashedVersions = [];
foreach ($manifest['versions'] as &$v) {
if ($doVersions) foreach ($manifest['versions'] as &$v) {
$version = $v['version'] ?? '?';
$entryId = (string)($v['id'] ?? '');
// Filtre par id d'entrée prioritaire sur filtre par version (cas
// channels multiples partageant un numéro de version identique —
// ex : proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32).
// Sinon, filtre par numéro de version (rétro-compat).
if ($onlyEntryId !== null && $entryId !== $onlyEntryId) {
continue;
}
if ($onlyEntryId === null && $onlyVersion !== null && $version !== $onlyVersion) {
continue; // skip silently les autres versions
}
$url = $v['download']['url'] ?? '';
if ($url === '') {
$this->out(" [skip] $version : pas d'URL dans le manifest");
@@ -72,19 +252,71 @@ final class SignManifest
}
}
$size = filesize($zip);
$sha = hash_file('sha256', $zip);
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$sha}");
$size = filesize($zip) ?: 0;
$v['download']['sizeBytes'] = $size;
$v['download']['sha256'] = $sha;
// Hash skipping : per-version flag dans le manifest. Si "none" ou skipHash=true,
// on neutralise le sha256 et la vérif côté client est passée. Utile pour les builds
// internes ou les très gros ZIPs où on accepte le compromis perf/sécurité (la
// signature Ed25519 du manifest reste). À utiliser avec parcimonie.
$algo = strtolower((string)($v['download']['hashAlgorithm'] ?? 'sha256'));
$skipFlag = !empty($v['download']['skipHash']);
if ($algo === 'none' || $skipFlag) {
$v['download']['sha256'] = '';
$v['download']['hashAlgorithm'] = 'none';
$this->out(" [skip-hash] $version : " . basename($zip) . " ($size octets) — hash non calculé (hashAlgorithm=none)");
$hashedVersions[] = $version;
continue;
}
// Cache lookup (sauf si --force)
if (!$force) {
$key = realpath($zip) ?: $zip;
$existingSha = (string)($v['download']['sha256'] ?? '');
$hasValidSha = $existingSha !== ''
&& !str_starts_with($existingSha, 'REPLACE');
if ($hasValidSha
&& isset($cache[$key])
&& ($cache[$key]['size'] ?? -1) === $size
&& ($cache[$key]['mtime'] ?? -1) === (filemtime($zip) ?: 0)
&& ($cache[$key]['sha256'] ?? '') === $existingSha) {
$this->out(" [cache] $version : " . basename($zip) . " ($size octets) — sha256 inchangé, hash cache hit");
$hashedVersions[] = $version;
continue;
}
}
// Log préalable au hash — utile pour identifier QUEL ZIP a fait
// planter le process si on retombe sur le 500. Sans ça, l'error log
// dit juste "PHP Fatal…" sans savoir si c'est le 1er ou le Nième ZIP.
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] START hash $version$zip ($size octets)\n",
FILE_APPEND);
try {
$r = $this->getOrComputeSha256($zip, $cache, $force);
} catch (\Throwable $e) {
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] EXCEPTION during hash of $version : "
. get_class($e) . " : {$e->getMessage()} @ {$e->getFile()}:{$e->getLine()}\n"
. $e->getTraceAsString() . "\n",
FILE_APPEND);
$this->out(" [ERROR] $version : hash failed — " . $e->getMessage());
continue;
}
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] DONE hash $version → sha256={$r['sha256']} ({$r['durationMs']} ms, fromCache=" . ($r['fromCache']?'1':'0') . ")\n",
FILE_APPEND);
$cacheChanged = true;
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
$v['download']['sha256'] = $r['sha256'];
$hashedVersions[] = $version;
}
unset($v);
// Section launcher : si présente, on tente de hasher le PSLauncher-{ver}.exe
// dans builds/launcher/. On ignore proprement si l'exe n'est pas là.
if (isset($manifest['launcher']) && is_array($manifest['launcher'])) {
if ($doLauncher && isset($manifest['launcher']) && is_array($manifest['launcher'])) {
$launcher = &$manifest['launcher'];
$lver = $launcher['version'] ?? '';
$lurl = $launcher['download']['url'] ?? '';
@@ -97,11 +329,27 @@ final class SignManifest
if (count($candidates) === 1) $lpath = $candidates[0];
}
if (is_file($lpath)) {
$lsize = filesize($lpath);
$lsha = hash_file('sha256', $lpath);
$lsize = filesize($lpath) ?: 0;
$launcher['download']['sizeBytes'] = $lsize;
$launcher['download']['sha256'] = $lsha;
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) sha256={$lsha}");
// Cache lookup pour le launcher exe aussi
$lkey = realpath($lpath) ?: $lpath;
$existingLsha = (string)($launcher['download']['sha256'] ?? '');
$hasValidLsha = $existingLsha !== '' && !str_starts_with($existingLsha, 'REPLACE');
if (!$force
&& $hasValidLsha
&& isset($cache[$lkey])
&& ($cache[$lkey]['size'] ?? -1) === $lsize
&& ($cache[$lkey]['mtime'] ?? -1) === (filemtime($lpath) ?: 0)
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
} else {
$r = $this->getOrComputeSha256($lpath, $cache, $force);
$cacheChanged = true;
$launcher['download']['sha256'] = $r['sha256'];
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) sha256={$r['sha256']} $note");
}
} else {
$this->out(" [launcher] v{$lver} : exe introuvable dans builds/launcher/");
}
@@ -109,15 +357,27 @@ final class SignManifest
unset($launcher);
}
// Bump auto de `latest` sur la plus haute version effectivement uploadée
if (!empty($hashedVersions)) {
usort($hashedVersions, 'version_compare');
$newLatest = end($hashedVersions);
// Bump auto de `latest` : prend la plus haute version dispo (sha256 valide OU
// hashAlgorithm=none) parmi TOUTES les entrées du manifest, pas seulement celles
// qu'on a re-hashé sur ce run. Évite de redescendre `latest` quand on hash une
// ancienne version isolée via $onlyVersion.
if ($doVersions) {
$eligible = [];
foreach ($manifest['versions'] as $vEntry) {
$sha = (string)($vEntry['download']['sha256'] ?? '');
$algo = strtolower((string)($vEntry['download']['hashAlgorithm'] ?? 'sha256'));
$hasValid = ($sha !== '' && !str_starts_with($sha, 'REPLACE')) || $algo === 'none';
if ($hasValid) $eligible[] = $vEntry['version'];
}
if (!empty($eligible)) {
usort($eligible, 'version_compare');
$newLatest = end($eligible);
if (($manifest['latest'] ?? null) !== $newLatest) {
$this->out(" [latest] " . ($manifest['latest'] ?? '(none)') . " -> {$newLatest}");
$manifest['latest'] = $newLatest;
}
}
}
// Signature Ed25519
if ($this->configPath && is_file($this->configPath)) {
@@ -145,6 +405,15 @@ final class SignManifest
return ['ok' => false, 'log' => $this->log];
}
// Persiste le cache de hashs si on l'a touché. On nettoie aussi les entrées
// obsolètes (fichiers supprimés) pour éviter qu'il grossisse indéfiniment.
if ($cacheChanged) {
foreach (array_keys($cache) as $cachedPath) {
if (!is_file($cachedPath)) unset($cache[$cachedPath]);
}
$this->saveHashCache($cache);
}
$this->out("Manifest mis à jour (" . count($hashedVersions) . " version(s)).");
return ['ok' => true, 'log' => $this->log];
}

View File

@@ -70,13 +70,42 @@ foreach ($files as $f) {
// Découpe naïve sur les ';' suivis d'un saut de ligne (suffit pour notre schéma)
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: []));
foreach ($statements as $stmt) {
if ($stmt === '' || str_starts_with($stmt, '--')) continue;
// Strip les lignes de commentaire SQL (--) du début / intercalées avant
// de checker si le statement est vide. Sinon : un fichier migration qui
// commence par un header de commentaires se retrouve dans le PREMIER
// chunk avec son premier ALTER (séparés par \n pas par ;\n) ; le chunk
// commence donc par `--`, le check str_starts_with le déclare vide,
// et l'ALTER est silencieusement skip. Bug rapporté : migrations 003+
// qui n'ont qu'un seul ALTER après un bloc de commentaires en tête
// n'étaient jamais appliquées (output vide entre "--- 003 ---" et "===").
$lines = preg_split('/\r?\n/', $stmt) ?: [];
$kept = [];
foreach ($lines as $line) {
$t = ltrim($line);
if ($t === '' || str_starts_with($t, '--')) continue;
$kept[] = $line;
}
$stmt = trim(implode("\n", $kept));
if ($stmt === '') continue;
try {
$pdo->exec($stmt);
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);
out("" . $first40 . "");
} catch (Exception $e) {
out(" ✘ ERREUR : " . $e->getMessage());
$msg = $e->getMessage();
// Tolérance idempotence : on n'aboie pas sur les ALTER qui re-touchent
// une colonne / un index existant. Permet de rejouer migrate.php sans
// risque sur une DB déjà partiellement / complètement migrée.
$idempotent = str_contains($msg, 'Duplicate column')
|| str_contains($msg, 'Duplicate key name')
|| str_contains($msg, "Can't DROP") // ALTER DROP COLUMN inexistante
|| str_contains($msg, 'check that column/key exists');
if ($idempotent) {
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);
out(" ↺ (déjà appliqué) " . $first40 . "");
continue;
}
out(" ✘ ERREUR : " . $msg);
out(" Sur : " . substr($stmt, 0, 80) . "");
exit(3);
}

View File

@@ -2,7 +2,11 @@
/**
* Wrapper CLI pour PSLauncher\Tools\SignManifest.
*
* Usage : cd ~/www/PS_Launcher && php tools/sign-manifest.php
* Usage :
* cd ~/www/PS_Launcher && php tools/sign-manifest.php
* php tools/sign-manifest.php --scope=launcher # ne re-signe que la section launcher
* php tools/sign-manifest.php --scope=versions # ne re-signe que les builds Proserve
* php tools/sign-manifest.php --force # ignore le cache, recalcule tous les SHA-256
*
* Le backoffice admin appelle directement la classe (pas d'exec).
*/
@@ -10,7 +14,15 @@ declare(strict_types=1);
require __DIR__ . '/SignManifest.php';
$scope = 'all';
$force = false;
foreach (array_slice($argv, 1) as $arg) {
if ($arg === '--force' || $arg === '-f') $force = true;
elseif (str_starts_with($arg, '--scope=')) $scope = substr($arg, 8);
elseif (in_array($arg, ['versions', 'launcher', 'all'], true)) $scope = $arg;
}
$signer = new \PSLauncher\Tools\SignManifest(dirname(__DIR__));
$result = $signer->run();
$result = $signer->run($scope, $force);
foreach ($result['log'] as $line) echo $line . "\n";
exit($result['ok'] ? 0 : 1);

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1,6 +1,7 @@
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -13,8 +14,18 @@ using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
using PSLauncher.Core.Integrity;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests;
using PSLauncher.Core.Security;
using PSLauncher.Core.ApiTool;
using PSLauncher.Core.DocTool;
using PSLauncher.Core.Lan;
using PSLauncher.Core.Health;
using PSLauncher.Core.Health.OpenVRInterop;
using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool;
using PSLauncher.Core.SteamVr;
using PSLauncher.Core.Updates;
using PSLauncher.Models;
using Serilog;
@@ -25,18 +36,77 @@ public partial class App : Application
{
private IHost? _host;
/// <summary>
/// IServiceProvider du host DI. Exposé pour les rares cas où une vue est
/// instanciée par <c>new</c> hors du container (ex: SettingsDialog) et a
/// besoin de résoudre un service.
/// </summary>
public IServiceProvider? HostServices => _host?.Services;
private static System.Threading.Mutex? _singleInstanceMutex;
private const string SingleInstanceMutexName = "Global\\PSLauncher-3F8E2C1A-9B47-4D5E-A0F8-2E9D1B6C7A3F";
private const string BringToFrontMessage = "PSLAUNCHER_BRING_TO_FRONT";
private static readonly int WM_PSLAUNCHER_BRING = RegisterWindowMessage(BringToFrontMessage);
[DllImport("user32.dll")]
private static extern int RegisterWindowMessage(string lpString);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam);
private const int HWND_BROADCAST = 0xFFFF;
public static string LogsDirectory { get; private set; } = string.Empty;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Single-instance : si une autre instance tourne déjà, on lui demande de
// se mettre au premier plan via un broadcast Windows et on quitte tout de suite.
_singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true,
name: SingleInstanceMutexName, out var createdNew);
if (!createdNew)
{
PostMessage((IntPtr)HWND_BROADCAST, WM_PSLAUNCHER_BRING, IntPtr.Zero, IntPtr.Zero);
Shutdown();
return;
}
// Logs avant tout — pour ne rien perdre des erreurs au boot
LogsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "logs");
Directory.CreateDirectory(LogsDirectory);
// WebView2 a besoin d'écrire son UserDataFolder (cache, cookies, GPU shader cache,
// etc.). Par défaut il essaie à côté du .exe — ce qui échoue silencieusement quand
// on est installé en Program Files (read-only sans admin) → onglets Report/Doc
// vides. On force le user-data-folder dans %LocalAppData%\PSLauncher\WebView2 via
// la var d'environnement officielle, lue par WebView2 à la première initialisation.
// À setter AVANT que tout contrôle WebView2 ne soit instancié → ici, dans OnStartup,
// est largement assez tôt (la MainWindow n'est créée que plus bas).
var webViewDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "WebView2");
Directory.CreateDirectory(webViewDataDir);
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", webViewDataDir, EnvironmentVariableTarget.Process);
// Bootstrap i18n : on lit d'abord la config (sans services DI) pour fixer la
// langue avant que le moindre élément XAML soit instancié.
try
{
var bootstrapStore = new ConfigStore(
Microsoft.Extensions.Logging.Abstractions.NullLogger<ConfigStore>.Instance);
var bootstrapCfg = bootstrapStore.Load();
Strings.Init(bootstrapCfg.Language);
}
catch
{
Strings.Init("auto");
}
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
@@ -50,8 +120,33 @@ public partial class App : Application
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
// RENDER MODE : sur certains environnements (RDP, VMs sans hardware accel,
// GPU émulé, Intel HD anciens, Windows Server sans Desktop Experience…)
// l'accélération matérielle WPF plante en silence : la fenêtre s'affiche
// toute blanche, aucun contenu ne rend, aucune exception ne remonte. C'est
// un bug classique non rattrapable côté .NET parce que le pipeline graphique
// tombe entre les mailles du try/catch. On force le rendu software dans 3 cas :
// 1. RenderCapability.Tier == 0 (WPF lui-même détecte aucune accel possible)
// 2. Session Bureau à distance (RDP) détectée
// 3. Var d'env PSLAUNCHER_SOFTWARE_RENDER=1 (override manuel pour debug)
// Trade-off : rendu software plus lent (~30% impact sur le scrolling de la
// liste des versions), mais au moins ça affiche QUELQUE CHOSE.
ApplyRenderModeFallback();
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
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) =>
{
Log.Error(args.Exception, "Unhandled UI exception");
@@ -67,13 +162,25 @@ public partial class App : Application
services.AddSingleton(sp =>
{
var http = new HttpClient(new SocketsHttpHandler
// Handler tuné pour les gros téléchargements parallèles :
// - MaxConnectionsPerServer=32 pour aligner avec le clamp DownloadManager (8-32).
// Default WPF = 8 → trop bas pour notre multi-segment.
// - AutomaticDecompression=None : les builds sont des ZIPs déjà compressés ;
// éviter la CPU+RAM gaspillée par un éventuel wrapping gzip côté serveur.
// - PooledConnectionLifetime court pour éviter qu'OVH ferme nos sockets sous nous.
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
AutomaticDecompression = System.Net.DecompressionMethods.All
})
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
MaxConnectionsPerServer = 32,
EnableMultipleHttp2Connections = true,
AutomaticDecompression = System.Net.DecompressionMethods.None,
};
var http = new HttpClient(handler)
{
Timeout = Timeout.InfiniteTimeSpan
Timeout = Timeout.InfiniteTimeSpan,
DefaultRequestVersion = System.Net.HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
};
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PSLauncher", "0.5"));
return http;
@@ -89,10 +196,20 @@ public partial class App : Application
services.AddSingleton<IZipInstaller, ZipInstaller>();
services.AddSingleton<IDownloadStateStore, DownloadStateStore>();
services.AddSingleton<IManifestCache, ManifestCache>();
services.AddSingleton<ISettingsLockService, SettingsLockService>();
services.AddSingleton<IManifestService>(sp =>
new ManifestService(
sp.GetRequiredService<HttpClient>(),
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
// Channel provider : lit le channel depuis la license cachée à
// chaque fetch (sp.GetRequiredService est lazy, donc pas de
// dépendance circulaire avec ILicenseService au démarrage).
// Si pas de license cachée → null → manifest default.
() => sp.GetRequiredService<ILicenseService>().GetCached()?.Channel,
sp.GetRequiredService<IManifestCache>(),
sp.GetRequiredService<IPeerManifestFetcher>(),
sp.GetRequiredService<ISettingsLockService>(),
sp.GetRequiredService<ILogger<ManifestService>>()));
services.AddSingleton<IDownloadManager, DownloadManager>();
@@ -105,25 +222,175 @@ public partial class App : Application
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
sp.GetRequiredService<IConfigStore>(),
sp.GetRequiredService<LocalConfig>(),
sp.GetRequiredService<ISettingsLockService>(),
sp.GetRequiredService<ILogger<LicenseService>>()));
services.AddSingleton<IToastService, ToastService>();
// Migration DB MySQL : appliquée juste après extraction d'un build PROSERVE
// si le ZIP contient un sous-répertoire _migrations/. La config DB et le
// password (DPAPI) sont lus à la demande pour refléter les changements
// utilisateur dans Settings sans recréer le service.
services.AddSingleton<IDatabaseMigrationService>(sp =>
new DatabaseMigrationService(
configProvider: () => sp.GetRequiredService<LocalConfig>().Database,
passwordProvider: () => DatabasePasswordProtector.Unprotect(
sp.GetRequiredService<LocalConfig>().Database.EncryptedPassword),
sp.GetRequiredService<ILogger<DatabaseMigrationService>>()));
// Déploiement de l'outil Report (page web XAMPP) à l'install d'une
// nouvelle version : copie atomique de _report/ → C:\xampp\htdocs\ProserveReport\
services.AddSingleton<IReportToolDeployer>(sp =>
new ReportToolDeployer(
configProvider: () => sp.GetRequiredService<LocalConfig>().ReportTool,
sp.GetRequiredService<ILogger<ReportToolDeployer>>()));
// Déploiement de l'outil Documentation (parallèle à Report) :
// copie atomique de _doc/ → C:\xampp\htdocs\ProserveDoc\
services.AddSingleton<IDocToolDeployer>(sp =>
new DocToolDeployer(
configProvider: () => sp.GetRequiredService<LocalConfig>().DocTool,
sp.GetRequiredService<ILogger<DocToolDeployer>>()));
// Déploiement de l'API PHP de stats (parallèle à Report/Doc) :
// copie atomique de _api/ → C:\xampp\htdocs\ProserveApi\
services.AddSingleton<IApiToolDeployer>(sp =>
new ApiToolDeployer(
configProvider: () => sp.GetRequiredService<LocalConfig>().ApiTool,
sp.GetRequiredService<ILogger<ApiToolDeployer>>()));
// Merge des settings SteamVR : si _steamvr/steamvr.vrsettings est
// présent dans le ZIP, tue SteamVR + Vive Business Streaming et
// fusionne les blocs racine dans le fichier de l'utilisateur
// (replace si existe, ajoute sinon). Voir SteamVrSettingsDeployer.
services.AddSingleton<ISteamVrSettingsDeployer>(sp =>
new SteamVrSettingsDeployer(
configProvider: () => sp.GetRequiredService<LocalConfig>().SteamVr,
sp.GetRequiredService<ILogger<SteamVrSettingsDeployer>>()));
// Cache LAN P2P : permet aux PCs du LAN de partager les ZIPs déjà
// téléchargés au lieu de re-télécharger 14 Go depuis OVH.
// - ZipCacheStore : couche de persistence (downloads + sidecar SHA)
// - LanDiscoveryService : auto-découverte UDP des peers
// - LanCacheServer : serveur HTTP local qui sert les ZIPs cachés
// - PeerSourceResolver : avant chaque DL, probe les peers et choisit
// le premier qui matche le SHA-256 attendu
services.AddSingleton<IZipCacheStore, ZipCacheStore>();
services.AddSingleton<LanDiscoveryService>(sp => new LanDiscoveryService(
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
cache: sp.GetRequiredService<IZipCacheStore>(),
configStore: sp.GetRequiredService<IConfigStore>(),
http: sp.GetRequiredService<HttpClient>(),
logger: sp.GetRequiredService<ILogger<LanDiscoveryService>>()));
services.AddSingleton<ILanDiscoveryService>(sp => sp.GetRequiredService<LanDiscoveryService>());
services.AddHostedService(sp => sp.GetRequiredService<LanDiscoveryService>());
services.AddSingleton<LanCacheServer>(sp => new LanCacheServer(
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
cache: sp.GetRequiredService<IZipCacheStore>(),
manifestCache: sp.GetRequiredService<IManifestCache>(),
logger: sp.GetRequiredService<ILogger<LanCacheServer>>()));
services.AddSingleton<ILanCacheServer>(sp => sp.GetRequiredService<LanCacheServer>());
services.AddHostedService(sp => sp.GetRequiredService<LanCacheServer>());
services.AddSingleton<IPeerSourceResolver>(sp => new PeerSourceResolver(
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
discovery: sp.GetRequiredService<ILanDiscoveryService>(),
http: sp.GetRequiredService<HttpClient>(),
logger: sp.GetRequiredService<ILogger<PeerSourceResolver>>()));
services.AddSingleton<IPeerManifestFetcher>(sp => new PeerManifestFetcher(
configProvider: () => sp.GetRequiredService<LocalConfig>().LanCache,
discovery: sp.GetRequiredService<ILanDiscoveryService>(),
http: sp.GetRequiredService<HttpClient>(),
logger: sp.GetRequiredService<ILogger<PeerManifestFetcher>>()));
services.AddSingleton<IOpenVrService, OpenVrService>();
services.AddSingleton<ISystemHealthService, SystemHealthService>();
services.AddTransient<SettingsViewModel>();
services.AddSingleton<MainViewModel>();
services.AddSingleton<MainWindow>();
})
.Build();
// Démarre les hosted services (LanCacheServer, LanDiscoveryService, …).
// Sans ce StartAsync, les BackgroundService.ExecuteAsync ne seraient
// jamais appelés — le DI les construit mais ne les "réveille" pas.
// Fire-and-forget : on ne bloque pas le UI thread, mais on garde l'erreur
// dans le log si un service plante au démarrage.
_ = _host.StartAsync().ContinueWith(t =>
{
if (t.IsFaulted) Log.Error(t.Exception, "Host StartAsync failed");
}, TaskScheduler.Default);
var window = _host.Services.GetRequiredService<MainWindow>();
window.DataContext = _host.Services.GetRequiredService<MainViewModel>();
// Mise en page droite-à-gauche pour les langues RTL (arabe).
if (Strings.IsRightToLeft)
window.FlowDirection = FlowDirection.RightToLeft;
MainWindow = window;
window.Show();
}
/// <summary>
/// Décide entre rendu hardware (par défaut WPF) et rendu software, en fonction
/// de l'environnement détecté + override par variable d'environnement. Log
/// la décision pour faciliter le debug si l'utilisateur reporte un écran blanc.
/// </summary>
private static void ApplyRenderModeFallback()
{
try
{
// Override manuel : PSLAUNCHER_SOFTWARE_RENDER=1 force le software.
// Le user peut le set dans les variables d'environnement Windows OU dans
// le raccourci .lnk (champ Cible : "cmd /c set PSLAUNCHER_SOFTWARE_RENDER=1 && start PS_Launcher.exe")
var envOverride = Environment.GetEnvironmentVariable("PSLAUNCHER_SOFTWARE_RENDER");
var manualSoftware = !string.IsNullOrEmpty(envOverride)
&& (envOverride == "1" || envOverride.Equals("true", StringComparison.OrdinalIgnoreCase));
// RenderCapability.Tier renvoie un int packed : (tier << 16). Tier 0 = pas
// d'accélération matérielle possible. Tier 1 = partielle. Tier 2 = complète.
var tier = System.Windows.Media.RenderCapability.Tier >> 16;
// GetSystemMetrics(SM_REMOTESESSION) = 1 si la session est une session RDP.
// SM_REMOTESESSION constant = 0x1000 (4096).
var isRdp = GetSystemMetrics(0x1000) != 0;
var shouldUseSoftware = manualSoftware || tier == 0 || isRdp;
Log.Information("Render env : Tier={Tier} RDP={Rdp} EnvOverride={EnvOverride} → SoftwareOnly={Sw}",
tier, isRdp, manualSoftware, shouldUseSoftware);
if (shouldUseSoftware)
{
System.Windows.Media.RenderOptions.ProcessRenderMode =
System.Windows.Interop.RenderMode.SoftwareOnly;
Log.Warning("WPF render mode forced to SoftwareOnly (cause: " +
(manualSoftware ? "env override" : tier == 0 ? "Tier=0 no GPU accel" : "RDP session") + ")");
}
}
catch (Exception ex)
{
// Pas critique : si le fallback échoue, on continue avec le default
// hardware accel et on espère que ça marche. Le user verra un écran
// blanc si non, mais au moins l'app aura pas crashé.
Log.Warning(ex, "Failed to evaluate render-mode fallback, keeping default");
}
}
[DllImport("user32.dll")]
private static extern int GetSystemMetrics(int nIndex);
protected override void OnExit(ExitEventArgs e)
{
Log.Information("PSLauncher shutting down");
// Stop propre des hosted services (laisse 5s pour qu'ils ferment leurs
// sockets HttpListener / UdpClient sans laisser le port en TIME_WAIT trop
// longtemps). Best-effort : si timeout, on continue le shutdown quand même.
try
{
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
_host?.StopAsync(stopCts.Token).GetAwaiter().GetResult();
}
catch (Exception ex) { Log.Warning(ex, "Host StopAsync failed (continuing shutdown)"); }
Log.CloseAndFlush();
_host?.Dispose();
base.OnExit(e);

View File

@@ -9,12 +9,18 @@
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon></ApplicationIcon>
<AssemblyName>PSLauncher</AssemblyName>
<!-- Le générateur WinRT (CsWinRT v2.x, déclenché par WebView2/UWP refs sur SDK récent)
émet du code unsafe dans WinRTGenericInstantiation.g.cs. Sans ce flag, build = CS0227. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationIcon>Resources\favicon.ico</ApplicationIcon>
<AssemblyName>PS_Launcher</AssemblyName>
<Company>ASTERION VR</Company>
<Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.5.0</Version>
<AssemblyVersion>0.5.0.0</AssemblyVersion>
<FileVersion>0.5.0.0</FileVersion>
<Version>1.0.13</Version>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
<FileVersion>1.0.13.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>
@@ -37,6 +43,11 @@
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<!-- WebView2 (Edge Chromium) pour les onglets Report et Documentation.
Le runtime est pré-installé sur Win11 et auto-mis-à-jour via Windows
Update sur Win10 récent. Si absent, le contrôle affiche une erreur
et on aiguille l'user vers https://go.microsoft.com/fwlink/p/?LinkId=2124703 -->
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3800.47" />
</ItemGroup>
<ItemGroup>
@@ -47,11 +58,14 @@
<ItemGroup>
<Resource Include="Resources\Background.png" />
<Resource Include="Resources\pirulen.otf" />
<Resource Include="Resources\favicon.ico" />
<Resource Include="Resources\logo-asterion-white.png" />
</ItemGroup>
<!-- Après chaque `dotnet publish -c Release`, copie le single-file exe à la racine du repo
(C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe) pour avoir un binaire prêt à tester
sans aller chercher dans bin\Release\... -->
(C:\ASTERION\GIT\PS_Launcher\PS_Launcher.exe). ContinueOnError pour ne pas casser le
publish si une instance du launcher tourne et verrouille la cible — l'opérateur peut
killer et réessayer, ou simplement utiliser le binaire depuis bin\Release\... -->
<Target Name="CopyPublishedToRepoRoot" AfterTargets="Publish"
Condition=" '$(Configuration)' == 'Release' ">
<PropertyGroup>
@@ -60,7 +74,8 @@
<Copy SourceFiles="$(PublishDir)$(AssemblyName).exe"
DestinationFolder="$(RepoRoot)"
SkipUnchangedFiles="false"
OverwriteReadOnlyFiles="true" />
OverwriteReadOnlyFiles="true"
ContinueOnError="WarnAndContinue" />
<Message Importance="high" Text="Copied $(AssemblyName).exe to $(RepoRoot)" />
</Target>

View File

@@ -0,0 +1,18 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace PSLauncher.App.Resources;
/// <summary>
/// Inverse de <see cref="System.Windows.Controls.BooleanToVisibilityConverter"/> : true → Collapsed, false → Visible.
/// Utile pour afficher un placeholder quand un binding bool est false (ex. "DocumentationUrl est vide").
/// </summary>
public sealed class InverseBoolToVisibilityConverter : IValueConverter
{
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is bool b && b ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is Visibility v && v != Visibility.Visible;
}

View File

@@ -3,6 +3,7 @@
xmlns:res="clr-namespace:PSLauncher.App.Resources">
<BooleanToVisibilityConverter x:Key="BoolToVisibility" />
<res:InverseBoolToVisibilityConverter x:Key="InverseBoolToVisibility" />
<!-- Custom font Pirulen pour les titres de marque -->
<FontFamily x:Key="Font.Brand">pack://application:,,,/Resources/#Pirulen</FontFamily>
@@ -10,12 +11,18 @@
<!-- Inverse bool : utilisé pour griser un bouton pendant qu'une opération est en cours -->
<res:InverseBoolConverter x:Key="InverseBoolToBool" />
<!-- Fond fenêtre : noir pur pour ne pas délaver le bitmap overlay -->
<SolidColorBrush x:Key="Brush.Bg.Window" Color="#000000" />
<!-- Cards & sidebar : très légère teinte bleutée pour le contraste -->
<SolidColorBrush x:Key="Brush.Bg.Sidebar" Color="#0E1218" />
<SolidColorBrush x:Key="Brush.Bg.Card" Color="#161B23" />
<SolidColorBrush x:Key="Brush.Bg.Footer" Color="#050709" />
<!-- Fond fenêtre : presque noir avec un soupçon de bleu. v0.18 a tenté
un navy plus prononcé (#0A1220) mais c'était trop bleu — on revient
à un noir-bleuté très subtil pour rester sombre tout en gardant
l'identité ASTERION VR. -->
<SolidColorBrush x:Key="Brush.Bg.Window" Color="#050A14" />
<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.Text.Primary" Color="#F2F2F2" />
<SolidColorBrush x:Key="Brush.Text.Secondary" Color="#A0A0A8" />
@@ -72,6 +79,47 @@
</Setter>
</Style>
<!--
NavButton : item de la sidebar de droite (Library / Report / Documentation).
Plein-largeur, alignement à gauche, pas de bordure, surligné quand actif
via Tag (bool) qui est lié à IsLibrary/IsReport/IsDocumentation.
-->
<Style x:Key="NavButton" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Secondary}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Padding" Value="20,12" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderThickness="3,0,0,0"
BorderBrush="Transparent"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Left" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1A1F2A" />
</Trigger>
<!-- "Tag" est utilisé comme flag actif (IsLibrary/IsReport/IsDocumentation) -->
<DataTrigger Binding="{Binding RelativeSource={RelativeSource Self}, Path=Tag}" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1F2937" />
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Brush.Accent}" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="{StaticResource Brush.Play}" />
<Setter Property="Foreground" Value="White" />
@@ -135,6 +183,39 @@
</Setter>
</Style>
<!--
DangerButton : action destructive (rouge). Utilisé pour le bouton « Annuler »
à côté de « ↻ Reprendre » sur les rows ayant un téléchargement partiel — pour
que l'utilisateur puisse abandonner le partial et repartir de zéro.
-->
<Style x:Key="DangerButton" TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
<Setter Property="Background" Value="#DC2626" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Padding" Value="14,8" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
CornerRadius="4"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#EF4444" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Background" Value="#444" />
<Setter Property="Foreground" Value="#888" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Boutons de chrome window (min / max / close), 46x40 façon Windows -->
<Style x:Key="WindowControlButton" TargetType="Button">
<Setter Property="Background" Value="Transparent" />
@@ -154,7 +235,8 @@
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#FFFFFF22" />
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.Accent}" />
<Setter Property="Foreground" Value="White" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -205,6 +287,177 @@
</Setter>
</Style>
<!-- ToolTip sombre.
Le ToolTip natif WPF est blanc + texte noir + corner radius zéro,
look Win 7-ish qui jure complètement avec le thème dark. On le
re-template : fond Bg.Card, bordure Border, texte Text.Primary,
coins arrondis, padding plus généreux, et MaxWidth pour que les
tooltips longs (ex. health banner avec 4-5 lignes de détail)
wrappent au lieu de filer en hors-écran. -->
<Style TargetType="ToolTip">
<Setter Property="Background" Value="{StaticResource Brush.Bg.Card}" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
<Setter Property="BorderBrush" Value="{StaticResource Brush.Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="FontSize" Value="12" />
<Setter Property="MaxWidth" Value="360" />
<Setter Property="HasDropShadow" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToolTip">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}"
CornerRadius="6">
<ContentPresenter>
<ContentPresenter.Resources>
<!-- ContentPresenter wrapping ToolTip Content :
si Content est une string, on veut un TextBlock
qui wrappe. Sinon c'est un visual user-fourni. -->
<Style TargetType="TextBlock">
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
</Style>
</ContentPresenter.Resources>
</ContentPresenter>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ComboBox sombre.
Re-template minimal pour avoir le contenu, l'arrow et le popup tous en
palette dark. WPF native ComboBox style serait grisé sur blanc. -->
<Style x:Key="ComboBoxToggleButton" TargetType="ToggleButton">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border Background="Transparent">
<Path x:Name="Arrow"
HorizontalAlignment="Center" VerticalAlignment="Center"
Data="M 0,0 L 8,0 L 4,5 Z"
Fill="{StaticResource Brush.Text.Secondary}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Arrow" Property="Fill" Value="{StaticResource Brush.Text.Primary}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBox">
<Setter Property="Background" Value="#1A1A20" />
<Setter Property="BorderBrush" Value="{StaticResource Brush.Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
<Setter Property="Padding" Value="8,4" />
<Setter Property="MinHeight" Value="32" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<!-- ToggleButton couvre TOUT le combo : clic au centre ouvre le dropdown,
pas seulement clic sur la flèche. Le ContentPresenter par-dessus
est en IsHitTestVisible=False pour ne pas voler le clic. -->
<ToggleButton x:Name="ToggleBtn"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Cursor="Hand"
Focusable="False"
IsChecked="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="28" />
</Grid.ColumnDefinitions>
<Path Grid.Column="1"
HorizontalAlignment="Center" VerticalAlignment="Center"
Data="M 0,0 L 8,0 L 4,5 Z"
Fill="{StaticResource Brush.Text.Secondary}" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource Brush.Accent}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="Left" VerticalAlignment="Center"
TextElement.Foreground="{TemplateBinding Foreground}" />
<Popup x:Name="PART_Popup"
Placement="Bottom"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True"
Focusable="False"
PopupAnimation="Slide">
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="1"
CornerRadius="4"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="300">
<ScrollViewer>
<ItemsPresenter />
</ScrollViewer>
</Border>
</Popup>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource Brush.Text.Primary}" />
<Setter Property="Padding" Value="10,6" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{StaticResource Brush.Accent}" />
<Setter Property="Foreground" Value="White" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#2C2C32" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Style ContextMenu sombre, cohérent avec le thème.
On retemplatise complètement pour virer la "icon column" claire que WPF
dessine par défaut sur le bord gauche du popup. -->

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

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

@@ -1,3 +1,4 @@
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
@@ -11,17 +12,7 @@ public sealed class InstalledVersionViewModel
public string Display => $"v{Model.Version}";
public string Subtitle =>
$"{FormatSize(Model.SizeBytes)} • Installée le {Model.InstalledAt.ToLocalTime():dd/MM/yyyy}";
private static string FormatSize(long bytes)
{
if (bytes <= 0) return "—";
string[] units = { "o", "Ko", "Mo", "Go", "To" };
double v = bytes;
int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
$"{Strings.FormatSize(Model.SizeBytes)} • {Strings.FormatDate(Model.InstalledAt)}";
public override string ToString() => Display;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.ViewModels;
@@ -9,6 +10,7 @@ public enum VersionRowState
InstalledIdle, // Installée localement, pas d'action en cours
AvailableIdle, // Disponible côté serveur, pas installée
Downloading,
Verifying, // SHA-256 du ZIP téléchargé en cours
Installing,
Uninstalling,
}
@@ -24,11 +26,79 @@ public sealed partial class VersionRowViewModel : ObservableObject
public VersionManifest? Remote { get; }
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
/// <summary>
/// Clé stable qui identifie une row au-delà du simple <see cref="Version"/>,
/// pour distinguer deux entrées manifest partageant le même numéro sur des
/// channels différents (ex : proserve-firefighter-1.5.4.32 vs proserve-full-
/// 1.5.4.32). Priorité :
/// <list type="number">
/// <item>Basename du dossier d'install (côté installed, ou côté remote
/// résolu via <c>InstallFolderTemplate</c>) — unique par convention car
/// deux entries au même version DOIVENT avoir des dossiers distincts
/// (validé backoffice + install guard client).</item>
/// <item>Fallback : le <see cref="Version"/> seul (mono-channel, comportement
/// identique à avant).</item>
/// </list>
/// Utilisé pour identifier une row lors des lookups par état actif (DL en
/// cours, install courant), au lieu de matcher par <c>Version</c> qui devient
/// ambigu sur les setups multi-channels.
/// </summary>
public string RowKey { get; }
/// <summary>
/// Channel principal à afficher comme badge sur la row (null / vide → pas
/// de badge). Sélection : le premier channel non-« default » de
/// <see cref="VersionManifest.Channels"/>, en <c>OrdinalIgnoreCase</c>. Pour
/// une row installée sans remote (orpheline), c'est null. Pour une entrée
/// remote sans Channels ou uniquement « default », c'est null.
/// </summary>
public string? ChannelBadge => Remote?.Channels?
.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c)
&& !string.Equals(c, "default", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// True si au moins une AUTRE row visible sur ce launcher partage le même
/// numéro de version. Positionné par <c>MainViewModel.RebuildList</c> après
/// construction des rows (group by Version, count &gt;= 2 → conflict).
/// Le badge channel n'est utile QUE dans ce cas — sinon la row est
/// univoque et le badge ajoute du bruit visuel.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasChannelBadge))]
private bool _hasVersionConflict;
/// <summary>
/// Le badge n'est visible QUE quand la row a un channel spécifique ET qu'une
/// autre row visible partage le même numéro de version. Ça garde l'UI propre
/// sur les setups mono-channel (99 % des cas) et affiche le distingueur
/// seulement quand il est réellement nécessaire.
/// </summary>
public bool HasChannelBadge => !string.IsNullOrEmpty(ChannelBadge) && HasVersionConflict;
/// <summary>
/// True si la version est taggée BÊTA dans le manifest (champ
/// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge
/// orange "BÊTA" + tooltip avec <see cref="BetaNotes"/>.
/// </summary>
public bool IsBeta => Remote?.IsBeta ?? false;
/// <summary>
/// Note des testeurs (texte libre). Affichée en tooltip quand on hover
/// le badge BÊTA. Null si non renseigné côté serveur.
/// </summary>
public string? BetaNotes => Remote?.BetaNotes;
/// <summary>Tooltip composé pour le hover sur le badge BÊTA.</summary>
public string BetaTooltip => string.IsNullOrEmpty(BetaNotes)
? Strings.BetaBadgeTooltipDefault
: Strings.BetaBadgeTooltip(BetaNotes!);
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StateLabel))]
[NotifyPropertyChangedFor(nameof(IsBusy))]
[NotifyPropertyChangedFor(nameof(IsInstalled))]
[NotifyPropertyChangedFor(nameof(IsRemoteOnly))]
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
[NotifyCanExecuteChangedFor(nameof(LaunchCommand))]
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
[NotifyCanExecuteChangedFor(nameof(UninstallCommand))]
@@ -40,6 +110,8 @@ public sealed partial class VersionRowViewModel : ObservableObject
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(InstallButtonLabel))]
[NotifyPropertyChangedFor(nameof(HasResumableDownload))]
[NotifyPropertyChangedFor(nameof(ShowRestartFromZero))]
[NotifyCanExecuteChangedFor(nameof(RestartFromZeroCommand))]
private long _resumableBytes;
[ObservableProperty]
@@ -48,37 +120,87 @@ public sealed partial class VersionRowViewModel : ObservableObject
[NotifyCanExecuteChangedFor(nameof(InstallCommand))]
private bool _licenseAllowsDownload = true;
/// <summary>
/// True quand cette version est la version désignée pour l'auto-launch
/// (bouton AUTO orange/coloré). Une seule row peut être sélectionnée à la
/// fois ; le MainViewModel maintient l'unicité quand on toggle.
/// </summary>
[ObservableProperty]
private bool _isAutoModeSelected;
/// <summary>
/// True si le bouton AUTO doit être affiché sur cette row (master toggle ON
/// + version installée). Piloté par le MainViewModel via property update à
/// chaque RebuildList.
/// </summary>
[ObservableProperty]
private bool _showAutoButton;
public Action<VersionRowViewModel>? ToggleAutoModeHandler { get; set; }
[RelayCommand]
private void ToggleAutoMode()
{
// Trace fichier pour diagnostiquer "le bouton AUTO ne fait rien".
// Si on voit cette ligne mais pas la suivante côté MainViewModel,
// c'est que le handler n'est pas attaché (bug WireRowHandlers).
try
{
var dir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "logs");
System.IO.Directory.CreateDirectory(dir);
System.IO.File.AppendAllText(
System.IO.Path.Combine(dir, "auto-mode.log"),
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z VersionRowViewModel.ToggleAutoMode invoked for v{Version} (handler attached={ToggleAutoModeHandler is not null}){Environment.NewLine}");
}
catch { }
ToggleAutoModeHandler?.Invoke(this);
}
public bool HasResumableDownload => ResumableBytes > 0;
public bool LicenseAllowsInstall => LicenseAllowsDownload;
/// <summary>
/// Visibilité du bouton rouge « Annuler » à côté de « ↻ Reprendre ».
/// Doit être vrai uniquement quand un partial existe ET qu'aucun DL n'est en
/// cours sur cette row (sinon le bouton « Annuler » se mélange visuellement
/// avec la barre de progression du DL actif, qui a son propre cancel).
/// </summary>
public bool ShowRestartFromZero => HasResumableDownload && State == VersionRowState.AvailableIdle;
public string InstallButtonLabel
{
get
{
if (!LicenseAllowsDownload) return "🔒 License insuffisante";
if (!HasResumableDownload) return "⬇ Installer";
if (Remote is null || Remote.Download.SizeBytes <= 0) return "↻ Reprendre";
var pct = (double)ResumableBytes / Remote.Download.SizeBytes * 100.0;
return $"↻ Reprendre ({pct:0}%)";
if (!LicenseAllowsDownload) return Strings.ActionLicenseInsufficient;
if (!HasResumableDownload) return Strings.ActionInstall;
if (Remote is null || Remote.Download.SizeBytes <= 0) return Strings.ResumeButtonNoPercent;
var pct = (int)Math.Round((double)ResumableBytes / Remote.Download.SizeBytes * 100.0);
return Strings.ResumeButton(pct);
}
}
public bool IsInstalled => State is VersionRowState.InstalledIdle or VersionRowState.Uninstalling;
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
{
VersionRowState.InstalledIdle => "● Installée",
VersionRowState.AvailableIdle => "○ Disponible",
VersionRowState.Downloading => "⬇ Téléchargement…",
VersionRowState.Installing => "📦 Installation…",
VersionRowState.Uninstalling => "🗑 Suppression…",
VersionRowState.InstalledIdle => Strings.StatusInstalled,
VersionRowState.AvailableIdle => Strings.StatusAvailable,
VersionRowState.Downloading => Strings.StatusDownloading,
VersionRowState.Verifying => Strings.StatusVerifying,
VersionRowState.Installing => Strings.StatusInstalling,
VersionRowState.Uninstalling => Strings.StatusUninstalling,
_ => string.Empty
};
public string PrimaryDate => ReleasedAt > DateTime.MinValue
? ReleasedAt.ToLocalTime().ToString("dd/MM/yyyy")
? Strings.FormatDate(ReleasedAt)
: "—";
public string SizeDisplay => FormatSize(SizeBytes);
@@ -124,6 +246,19 @@ public sealed partial class VersionRowViewModel : ObservableObject
Installed = installed;
Remote = remote;
_state = initialState;
// RowKey — priorité : (1) Id de l'entrée manifest (unique par entry, survit
// à un rename de folder), (2) EntryId lu depuis le .proserve-meta.json de
// l'install, (3) folder name basename, (4) version en dernier recours.
// Doit matcher la logique de MainViewModel.RebuildList pour que les lookups
// par RowKey trouvent la row correcte.
var folderName = !string.IsNullOrEmpty(folderPath)
? System.IO.Path.GetFileName(folderPath)
: remote?.GetInstallFolderName();
RowKey = !string.IsNullOrEmpty(remote?.Id) ? remote!.Id!
: !string.IsNullOrEmpty(installed?.EntryId) ? installed!.EntryId!
: !string.IsNullOrWhiteSpace(folderName) ? folderName!
: version;
}
// Les commandes sont câblées par le MainViewModel après instanciation
@@ -134,6 +269,8 @@ public sealed partial class VersionRowViewModel : ObservableObject
public Action<VersionRowViewModel>? UninstallHandler { get; set; }
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
public Action<VersionRowViewModel>? ForceFreshDownloadHandler { get; set; }
[RelayCommand(CanExecute = nameof(CanLaunch))]
private void Launch() => LaunchHandler?.Invoke(this);
@@ -153,13 +290,19 @@ public sealed partial class VersionRowViewModel : ObservableObject
[RelayCommand]
private void OpenFolder() => OpenFolderHandler?.Invoke(this);
[RelayCommand(CanExecute = nameof(CanRestartFromZero))]
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
private bool CanRestartFromZero() => HasResumableDownload;
/// <summary>
/// Visible quand la row n'est pas en cours de DL (sinon le user devrait
/// d'abord cancel via le bouton dédié). Pas conditionnée par "partial existe"
/// car cette action a pour but spécifique de purger TOUT (cache + partial +
/// sidecar SHA), même quand il n'y a pas de partial en cours.
/// </summary>
[RelayCommand]
private void ForceFreshDownload() => ForceFreshDownloadHandler?.Invoke(this);
private static string FormatSize(long bytes)
{
if (bytes <= 0) return "—";
string[] units = { "o", "Ko", "Mo", "Go", "To" };
double v = bytes;
int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes);
}

View File

@@ -0,0 +1,72 @@
<Window x:Class="PSLauncher.App.Views.AutoRelaunchDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.AutoRelaunchTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="560" Height="420"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Topmost="True"
Background="{StaticResource Brush.Bg.Window}">
<Grid Margin="36,32">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Titre dynamique : "Vérification de la santé système" pendant la
phase d'attente, "Lancement automatique" pendant le countdown. -->
<TextBlock Grid.Row="0"
x:Name="TitleText"
Text="{x:Static loc:Strings.AutoRelaunchTitle}"
FontSize="22" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}"
HorizontalAlignment="Center" />
<TextBlock Grid.Row="1"
x:Name="MessageText"
FontSize="14"
Foreground="{StaticResource Brush.Text.Secondary}"
TextWrapping="Wrap"
TextAlignment="Center"
Margin="0,16,0,0" />
<!-- Affiché pendant la phase santé : compteur d'indicateurs en attente
+ nom des checks non-OK. Masqué pendant le countdown. -->
<TextBlock Grid.Row="2"
x:Name="HealthPendingText"
FontSize="13"
Foreground="#FBBF24"
TextWrapping="Wrap"
TextAlignment="Center"
Margin="0,12,0,0"
Visibility="Collapsed" />
<!-- Affiché pendant le countdown. Gros chiffre orange. -->
<TextBlock Grid.Row="2"
x:Name="CountdownText"
FontSize="72"
FontWeight="Bold"
HorizontalAlignment="Center"
Foreground="{StaticResource Brush.Accent}"
Margin="0,20,0,0"
Visibility="Collapsed" />
<!-- Row 3 = espacement * pour aérer entre le contenu et le bouton -->
<Button Grid.Row="4"
Style="{StaticResource DangerButton}"
Content="{x:Static loc:Strings.AutoRelaunchCancel}"
FontSize="14"
Padding="40,12"
HorizontalAlignment="Center"
Margin="0,16,0,0"
IsCancel="True"
IsDefault="True"
Click="OnCancel" />
</Grid>
</Window>

View File

@@ -0,0 +1,162 @@
using System.Windows;
using System.Windows.Threading;
using PSLauncher.Core.Localization;
namespace PSLauncher.App.Views;
/// <summary>
/// Modal de lancement automatique de PROSERVE. Utilisé pour les 3 entry points
/// du mode auto :
/// 1. Click sur le bouton AUTO d'une row → désigne + lance
/// 2. Démarrage du launcher avec mode auto configuré → lance
/// 3. PROSERVE quitte alors qu'il était la version auto → relance
///
/// Deux phases possibles selon <see cref="AutoModeConfig.WaitForHealthChecks"/> :
/// • <b>Phase santé</b> (optionnelle) : si la config dit d'attendre que tous
/// les indicateurs de santé système soient OK, on poll le callback
/// <see cref="_healthSnapshot"/> tant qu'il retourne une liste non vide.
/// Le bouton Annuler permet de sortir et désactiver le mode auto.
/// • <b>Phase countdown</b> : décompte visuel de N secondes avant lancement
/// (configurable via GracePeriodSeconds, clampé 1-60).
///
/// DialogResult :
/// <c>true</c> = phase countdown terminée à zéro, MainViewModel doit lancer
/// <c>false</c> = l'opérateur a cliqué Annuler (à n'importe quelle phase),
/// MainViewModel doit désactiver SelectedVersion
/// </summary>
public partial class AutoRelaunchDialog : Window
{
private readonly DispatcherTimer _timer;
private readonly int _graceSeconds;
private readonly string _versionName;
/// <summary>
/// Callback fourni par MainViewModel pour interroger l'état de santé.
/// Retourne la liste des NOMS d'indicateurs qui ne sont PAS encore OK
/// (Warning, Error, ou Unknown). Liste vide ⇒ tout est OK ⇒ on peut
/// passer à la phase countdown. Null si la phase santé n'est pas
/// demandée pour ce lancement (option WaitForHealthChecks=false ou
/// aucun health check configuré).
/// </summary>
private readonly Func<IReadOnlyList<string>>? _healthSnapshot;
private enum Phase { HealthWait, Countdown }
private Phase _phase;
private int _remainingSeconds;
public AutoRelaunchDialog(string versionName, int graceSeconds,
Func<IReadOnlyList<string>>? healthSnapshot = null)
{
InitializeComponent();
_versionName = versionName;
_graceSeconds = Math.Clamp(graceSeconds, 1, 60);
_remainingSeconds = _graceSeconds;
_healthSnapshot = healthSnapshot;
// Si on a un callback santé ET qu'au premier check il y a des
// indicateurs en attente, on démarre en phase santé. Sinon on saute
// direct au countdown (cas WaitForHealthChecks=false OU tout est
// déjà OK au moment où le dialog s'ouvre).
var initialPending = _healthSnapshot?.Invoke() ?? Array.Empty<string>();
if (initialPending.Count > 0)
{
_phase = Phase.HealthWait;
EnterHealthWaitPhase(initialPending);
}
else
{
_phase = Phase.Countdown;
EnterCountdownPhase();
}
// Un seul timer pour les deux phases : poll santé toutes les 1s ou
// décrémente le countdown toutes les 1s. Simplifie le cleanup.
_timer = new DispatcherTimer(DispatcherPriority.Normal)
{
Interval = TimeSpan.FromSeconds(1),
};
_timer.Tick += OnTick;
_timer.Start();
}
private void EnterHealthWaitPhase(IReadOnlyList<string> pending)
{
TitleText.Text = Strings.AutoLaunchHealthWaitTitle;
MessageText.Text = Strings.AutoLaunchHealthWaitMessage;
HealthPendingText.Visibility = Visibility.Visible;
CountdownText.Visibility = Visibility.Collapsed;
UpdatePendingText(pending);
}
private void EnterCountdownPhase()
{
TitleText.Text = Strings.AutoRelaunchTitle;
MessageText.Text = Strings.AutoRelaunchMessage(_versionName);
HealthPendingText.Visibility = Visibility.Collapsed;
CountdownText.Visibility = Visibility.Visible;
_remainingSeconds = _graceSeconds;
UpdateCountdown();
}
private void UpdatePendingText(IReadOnlyList<string> pending)
{
// Ligne 1 : "Indicateurs en attente : N". Ligne 2 : noms séparés par ' • '.
// On limite à 4 noms affichés pour ne pas casser le layout si l'opérateur
// a configuré 15 checks. Le tooltip système couvre le détail complet.
var header = Strings.AutoLaunchHealthWaitPending(pending.Count);
var preview = pending.Count <= 4
? string.Join(" • ", pending)
: string.Join(" • ", pending.Take(4)) + " …";
HealthPendingText.Text = $"{header}\n{preview}";
}
private void OnTick(object? sender, EventArgs e)
{
if (_phase == Phase.HealthWait)
{
var pending = _healthSnapshot?.Invoke() ?? Array.Empty<string>();
if (pending.Count == 0)
{
// Transition vers countdown : tout est OK maintenant.
_phase = Phase.Countdown;
EnterCountdownPhase();
}
else
{
UpdatePendingText(pending);
}
return;
}
// Phase.Countdown
_remainingSeconds--;
if (_remainingSeconds <= 0)
{
_timer.Stop();
DialogResult = true; // lancement
Close();
}
else
{
UpdateCountdown();
}
}
private void UpdateCountdown()
{
CountdownText.Text = _remainingSeconds.ToString();
}
private void OnCancel(object sender, RoutedEventArgs e)
{
_timer.Stop();
DialogResult = false; // annulation
Close();
}
protected override void OnClosed(EventArgs e)
{
_timer.Stop();
base.OnClosed(e);
}
}

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

@@ -1,7 +1,9 @@
<Window x:Class="PSLauncher.App.Views.LauncherUpdateDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Mise à jour du launcher"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.LauncherUpdateTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="500" Height="320"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
@@ -15,7 +17,7 @@
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="Mise à jour du launcher disponible"
Text="{x:Static loc:Strings.LauncherUpdateAvailable}"
FontSize="20" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}" />
@@ -32,7 +34,7 @@
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="16" Margin="0,16,0,0">
<TextBlock Text="Le launcher va se mettre à jour : il téléchargera le nouveau binaire, se fermera, sera remplacé, puis se relancera automatiquement. Cela prend quelques secondes."
<TextBlock Text="{x:Static loc:Strings.LauncherUpdateBody}"
Foreground="{StaticResource Brush.Text.Secondary}"
TextWrapping="Wrap" />
</Border>
@@ -40,11 +42,11 @@
<StackPanel Grid.Row="3" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,18,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="Plus tard" IsCancel="True"
Content="{x:Static loc:Strings.ActionLater}" IsCancel="True"
Margin="0,0,12,0"
Click="OnLater" />
<Button Style="{StaticResource AccentButton}"
Content="⬇ Mettre à jour"
Content="{x:Static loc:Strings.LauncherUpdateNow}"
Padding="32,8"
IsDefault="True"
Click="OnUpdate" />

View File

@@ -1,4 +1,5 @@
using System.Windows;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.Views;
@@ -12,7 +13,7 @@ public partial class LauncherUpdateDialog : Window
InitializeComponent();
VersionText.Text = $"v{currentVersion} → v{info.Version}";
SizeText.Text = info.Download.SizeBytes > 0
? $"Taille : {FormatSize(info.Download.SizeBytes)}"
? Strings.SizeLabel(Strings.FormatSize(info.Download.SizeBytes))
: "";
}
@@ -29,13 +30,4 @@ public partial class LauncherUpdateDialog : Window
DialogResult = false;
Close();
}
private static string FormatSize(long bytes)
{
if (bytes <= 0) return "—";
string[] units = { "o", "Ko", "Mo", "Go" };
double v = bytes; int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
}

View File

@@ -1,8 +1,12 @@
<Window x:Class="PSLauncher.App.Views.LicenseDetailsDialog"
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="License"
Width="500" Height="480"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="540" Height="540"
SizeToContent="Height"
MinHeight="480"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Brush.Bg.Window}">
@@ -14,24 +18,34 @@
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Header avec badge couleur -->
<!-- Header avec badge couleur. On utilise un Grid (Auto + *) pour que la
colonne texte prenne tout l'espace disponible et que le sous-titre
puisse wrapper sur plusieurs lignes (les messages "License expirée
le X. Vous pouvez encore..." font facilement 2-3 lignes). -->
<Border Grid.Row="0" CornerRadius="8" Padding="20,16"
BorderThickness="2"
x:Name="HeaderBorder">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="StatusIconText"
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
x:Name="StatusIconText"
FontSize="32" FontWeight="Bold"
VerticalAlignment="Center" Margin="0,0,16,0" />
<StackPanel VerticalAlignment="Center">
VerticalAlignment="Top" Margin="0,0,16,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock x:Name="StatusTitleText"
FontSize="18" FontWeight="SemiBold"
TextWrapping="Wrap"
Foreground="{StaticResource Brush.Text.Primary}" />
<TextBlock x:Name="StatusSubtitleText"
FontSize="13"
TextWrapping="Wrap"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,2,0,0" />
</StackPanel>
Margin="0,4,0,0" />
</StackPanel>
</Grid>
</Border>
<!-- Détails -->
@@ -44,7 +58,7 @@
<ColumnDefinition Width="160" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Client"
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsClient}"
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
<TextBlock Grid.Column="1" x:Name="OwnerText"
FontWeight="SemiBold"
@@ -55,7 +69,7 @@
<ColumnDefinition Width="160" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Validité téléchargements"
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsValidity}"
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
<TextBlock Grid.Column="1" x:Name="UntilText"
FontWeight="SemiBold"
@@ -66,7 +80,7 @@
<ColumnDefinition Width="160" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Activée le"
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsActivatedOn}"
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12" />
<TextBlock Grid.Column="1" x:Name="IssuedText"
Foreground="{StaticResource Brush.Text.Primary}" />
@@ -76,7 +90,7 @@
<ColumnDefinition Width="160" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="ID machine"
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.LicenseDetailsMachineId}"
Foreground="{StaticResource Brush.Text.Secondary}" FontSize="12"
VerticalAlignment="Center" />
<Grid Grid.Column="1">
@@ -90,7 +104,7 @@
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Button Grid.Column="1" Style="{StaticResource SecondaryButton}"
Content="📋"
ToolTip="Copier"
ToolTip="{x:Static loc:Strings.CopyTooltip}"
Click="OnCopyMachineId" />
</Grid>
</Grid>
@@ -100,11 +114,11 @@
<!-- Footer actions -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="🗑 Désactiver la license"
Content="{x:Static loc:Strings.LicenseDetailsDeactivate}"
Click="OnDeactivate"
Margin="0,0,12,0" />
<Button Style="{StaticResource AccentButton}"
Content="Fermer"
Content="{x:Static loc:Strings.ActionClose}"
IsCancel="True" IsDefault="True"
Padding="32,8"
Click="OnClose" />

View File

@@ -1,6 +1,7 @@
using System.Windows;
using System.Windows.Media;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.Views;
@@ -28,11 +29,11 @@ public partial class LicenseDetailsDialog : Window
OwnerText.Text = license.OwnerName ?? "—";
UntilText.Text = license.DownloadEntitlementUntil is { } until
? $"jusqu'au {until.ToLocalTime():dddd dd MMMM yyyy}"
? Strings.FormatLongDate(until)
: "—";
IssuedText.Text = license.IssuedAt is { } issued
? issued.ToLocalTime().ToString("dd/MM/yyyy")
: (license.ServerTime?.ToLocalTime().ToString("dd/MM/yyyy") ?? "—");
? Strings.FormatDate(issued)
: Strings.FormatDate(license.ServerTime);
MachineIdText.Text = licenseService.GetMachineId();
}
@@ -42,19 +43,19 @@ public partial class LicenseDetailsDialog : Window
if (l.Status == "valid")
{
if (until is { } u && (u - DateTime.UtcNow).TotalDays < 30)
return ("⏰", "License valide", $"Expire bientôt — le {u.ToLocalTime():dd/MM/yyyy}",
return ("⏰", Strings.LicenseDetailsValidTitle, Strings.LicenseDetailsExpiringSoon(Strings.FormatDate(u)),
Color.FromRgb(0xF5, 0x9E, 0x0B), Color.FromRgb(0x3A, 0x2A, 0x0E));
return ("✓", "License valide", "Téléchargements de nouvelles versions autorisés",
return ("✓", Strings.LicenseDetailsValidTitle, Strings.LicenseDetailsValidSubtitle,
Color.FromRgb(0x16, 0xA3, 0x4A), Color.FromRgb(0x0E, 0x2A, 0x1B));
}
if (l.Status == "expired")
return ("⚠", "License expirée",
until is { } u ? $"Expirée le {u.ToLocalTime():dd/MM/yyyy}" : "Plus de téléchargements possibles",
return ("⚠", Strings.LicenseDetailsExpiredTitle,
until is { } u ? Strings.LicenseDetailsExpiredSubtitle(Strings.FormatDate(u)) : Strings.LicenseDetailsExpiredFallback,
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
if (l.Status == "revoked")
return ("🚫", "License révoquée", "Contacte ASTERION pour plus d'informations",
return ("🚫", Strings.LicenseDetailsRevokedTitle, Strings.LicenseDetailsRevokedSubtitle,
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
return ("❓", "License inconnue", l.Message ?? l.Status,
return ("❓", Strings.LicenseDetailsUnknownTitle, l.Message ?? l.Status,
Color.FromRgb(0xEF, 0x44, 0x44), Color.FromRgb(0x2A, 0x14, 0x14));
}
@@ -65,11 +66,9 @@ public partial class LicenseDetailsDialog : Window
private void OnDeactivate(object sender, RoutedEventArgs e)
{
var confirm = MessageBox.Show(
"Désactiver la license sur cette machine ?\n\n" +
"Tu pourras la réactiver plus tard avec ta clé. " +
"Les versions déjà installées restent utilisables.",
"Confirmer la désactivation",
var confirm = ThemedMessageBox.Show(
Strings.MsgDeactivateLicenseDetailedConfirm,
Strings.MsgBoxConfirm,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
_licenseService.Clear();

View File

@@ -2,12 +2,16 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:PSLauncher.App.ViewModels"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
Title="PROSERVE Launcher"
Icon="pack://application:,,,/Resources/favicon.ico"
Background="Black"
Width="1280" Height="720"
MinWidth="980" MinHeight="600"
WindowStartupLocation="CenterScreen"
WindowState="Maximized"
WindowStyle="None"
ResizeMode="CanResize"
AllowsTransparency="False"
@@ -53,22 +57,23 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Strip latérale colorée -->
<Rectangle Grid.Column="0" RadiusX="2" RadiusY="2" Margin="0,8">
<Rectangle.Style>
<Style TargetType="Rectangle">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Installed}" />
<!-- Strip latérale colorée : Border avec coins arrondis à gauche
pour suivre la rondeur (CornerRadius=6) de la card parent. -->
<Border Grid.Column="0" CornerRadius="5,0,0,5">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{StaticResource Brush.Status.Installed}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsRemoteOnly}" Value="True">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Available}" />
<Setter Property="Background" Value="{StaticResource Brush.Status.Available}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsBusy}" Value="True">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Busy}" />
<Setter Property="Background" Value="{StaticResource Brush.Status.Busy}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
</Border.Style>
</Border>
<Grid Grid.Column="1" Margin="16,12">
<Grid.RowDefinitions>
@@ -80,6 +85,31 @@
FontSize="15" FontWeight="SemiBold"
VerticalAlignment="Center"
Foreground="{StaticResource Brush.Text.Primary}" />
<!-- Badge BÊTA pour les rows secondaires (compact). Même look
que celui du featured mais en plus petit pour matcher la row. -->
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
VerticalAlignment="Center"
Background="#F59E0B"
ToolTip="{Binding BetaTooltip}"
Visibility="{Binding IsBeta, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
FontSize="10" FontWeight="Bold"
Foreground="#1A0A00" />
</Border>
<!-- Badge CHANNEL : violet, à côté des autres badges. Affiché
quand la row est sur un channel spécifique (non-default) et
qu'une autre row visible partage le même numéro de version.
Couleur violette #8B5CF6 pour se distinguer nettement du
badge BETA orange et du bleu du statut "Available". -->
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
VerticalAlignment="Center"
Background="#8B5CF6"
ToolTip="{Binding ChannelBadge}"
Visibility="{Binding HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding ChannelBadge}"
FontSize="10" FontWeight="Bold"
Foreground="White" />
</Border>
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
VerticalAlignment="Center">
<Border.Style>
@@ -110,14 +140,43 @@
<!-- Action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0">
<!-- Bouton AUTO : sélectionne cette version pour l'auto-launch
au démarrage du launcher. Fond gris quand inactif, orange
quand sélectionné. Visible seulement si FeatureEnabled. -->
<Button Content="{x:Static loc:Strings.ActionAuto}"
Padding="14,8" FontSize="13" FontWeight="Bold"
Margin="0,0,6,0"
Command="{Binding ToggleAutoModeCommand}"
Visibility="{Binding ShowAutoButton, Converter={StaticResource BoolToVisibility}}">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource SecondaryButton}">
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipInactive}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsAutoModeSelected}" Value="True">
<Setter Property="Background" Value="#F5A623" />
<Setter Property="Foreground" Value="White" />
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipActive}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button Style="{StaticResource PrimaryButton}"
Content="Lancer" Padding="22,8" FontSize="13"
Content="{x:Static loc:Strings.ActionLaunch}" Padding="22,8" FontSize="13"
Command="{Binding LaunchCommand}"
Visibility="{Binding IsInstalled, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButton}"
Content="{Binding InstallButtonLabel}" Padding="22,8" FontSize="13"
Command="{Binding InstallCommand}"
Visibility="{Binding IsRemoteOnly, Converter={StaticResource BoolToVisibility}}" />
<!-- Bouton rouge « Annuler » : visible uniquement quand on a un partial.
Permet d'abandonner le partial et repartir de zéro (avec confirmation). -->
<Button Style="{StaticResource DangerButton}"
Content="{x:Static loc:Strings.ActionCancel}"
Padding="14,8" FontSize="13" Margin="6,0,0,0"
Command="{Binding RestartFromZeroCommand}"
ToolTip="{x:Static loc:Strings.MenuRestartFromZero}"
Visibility="{Binding ShowRestartFromZero, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Orientation="Horizontal"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}">
<ProgressBar Width="140" Height="6"
@@ -141,13 +200,18 @@
Click="OnMoreMenuClick">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="📜 Voir les release notes"
<MenuItem Header="{x:Static loc:Strings.MenuReleaseNotes}"
Command="{Binding PlacementTarget.Tag.ShowReleaseNotesCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<MenuItem Header="📁 Ouvrir le dossier"
<MenuItem Header="{x:Static loc:Strings.MenuOpenFolder}"
Command="{Binding PlacementTarget.Tag.OpenFolderCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="🗑 Supprimer cette version…"
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
</ContextMenu>
@@ -158,25 +222,59 @@
</DataTemplate>
</Window.Resources>
<Grid Background="Black">
<!--
Layout principal — grille 2 colonnes × 3 rangées :
┌─────────────────────────────────────────────────────────────┐
│ Top bar (ColSpan 2) │ Row 0
├──────────┬──────────────────────────────────────────────────┤
│ │ │
│ Sidebar │ Content area │ Row 1
│ (Library │ (Library / Report / Doc) │
│ Report │ │
│ Doc) ├──────────────────────────────────────────────────┤
│ RowSpan2 │ Footer (DL/install) │ Row 2
│ │ visible only on Library │
└──────────┴──────────────────────────────────────────────────┘
200px *
La sidebar a RowSpan=2 → s'étend du sous-topbar jusqu'au bas
de fenêtre. Le footer est confiné à la colonne content.
-->
<Grid Background="{StaticResource Brush.Bg.Window}">
<!-- La fenêtre est correctement clampée à la work area du moniteur en mode
Maximized via WM_GETMINMAXINFO (cf. MainWindow.xaml.cs). Plus besoin
de hack via Margin sur trigger WindowState. -->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" /> <!-- Top bar -->
<RowDefinition Height="Auto" /> <!-- Health banner -->
<RowDefinition Height="*" /> <!-- Body (sidebar + content) -->
<RowDefinition Height="Auto" /> <!-- Footer -->
</Grid.RowDefinitions>
<!-- Background image (sous tout le reste, ne capte pas la souris).
Le fond noir du Grid garantit que les zones transparentes de l'image
(et l'image dans son ensemble à faible opacity) reposent sur du noir
pur, sans héritage du brush système. -->
<Image Grid.Row="0" Grid.RowSpan="3"
Source="pack://application:,,,/Resources/Background.png"
<!-- Background image : limité à la zone content (Row 2, Col 1) pour ne pas
passer derrière la sidebar, le banner ou le footer. -->
<Rectangle Grid.Row="2" Grid.Column="1" IsHitTestVisible="False">
<Rectangle.Fill>
<ImageBrush ImageSource="pack://application:,,,/Resources/Background.png"
Stretch="UniformToFill"
Opacity="0.25"
AlignmentX="Right"
AlignmentY="Bottom"
Opacity="0.25" />
</Rectangle.Fill>
</Rectangle>
<Rectangle Grid.Row="2" Grid.Column="1"
Fill="{StaticResource Brush.Bg.BlueTint}"
IsHitTestVisible="False" />
<!-- Top bar : 3 colonnes (brand / license center / chrome) -->
<Border Grid.Row="0" Background="{StaticResource Brush.Bg.Sidebar}"
<!-- Top bar : 3 colonnes (brand / license center / chrome).
Grid.ColumnSpan=2 → s'étend sur sidebar + content. -->
<Border Grid.Row="0" Grid.ColumnSpan="2" Background="{StaticResource Brush.Bg.Sidebar}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,0,0,1"
Padding="20,12">
<Grid>
@@ -187,6 +285,12 @@
</Grid.ColumnDefinitions>
<!-- Col 1 : brand -->
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<Image Source="pack://application:,,,/Resources/logo-asterion-white.png"
Height="36"
VerticalAlignment="Center"
Margin="0,0,14,0"
SnapsToDevicePixels="True"
RenderOptions.BitmapScalingMode="HighQuality" />
<TextBlock Text="PROSERVE"
FontFamily="{StaticResource Font.Brand}"
FontSize="22"
@@ -206,7 +310,7 @@
Cursor="Hand"
CornerRadius="16" Padding="14,6"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
ToolTip="Voir / changer la license">
ToolTip="{x:Static loc:Strings.LicenseTooltip}">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#2A1414" />
@@ -270,28 +374,79 @@
<StackPanel Grid.Column="2" Orientation="Horizontal"
HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Style="{StaticResource SecondaryButton}"
Content="⚙ Paramètres"
Content="{x:Static loc:Strings.TopBarSettings}"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
Command="{Binding OpenSettingsCommand}"
Margin="0,0,16,0" />
<Button Style="{StaticResource WindowControlButton}"
Content="—" ToolTip="Réduire"
Content="—" ToolTip="{x:Static loc:Strings.Minimize}"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
Click="OnMinimizeClick" />
<Button Style="{StaticResource WindowControlButton}"
Content="☐" ToolTip="Agrandir / Restaurer"
Content="☐" ToolTip="{x:Static loc:Strings.Maximize}"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
Click="OnMaxRestoreClick" />
<Button Style="{StaticResource WindowCloseButton}"
Content="✕" ToolTip="Fermer"
Content="✕" ToolTip="{x:Static loc:Strings.CloseTooltip}"
shell:WindowChrome.IsHitTestVisibleInChrome="True"
Click="OnCloseClick" />
</StackPanel>
</Grid>
</Border>
<!-- Body : Grid pour pouvoir superposer le bouton flottant "Vérifier les MAJ" -->
<Grid Grid.Row="1">
<!-- ============== 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
leur Visibility liée à CurrentPage. -->
<Grid Grid.Row="2" Grid.Column="1">
<!-- ====================== LIBRARY PAGE ====================== -->
<Grid Visibility="{Binding IsLibrary, Converter={StaticResource BoolToVisibility}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="32,28">
@@ -321,22 +476,25 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Bande latérale large -->
<Rectangle Grid.Column="0">
<Rectangle.Style>
<Style TargetType="Rectangle">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Installed}" />
<!-- Bande latérale large : Border avec coins arrondis seulement à
gauche pour suivre la rondeur de la card parent (CornerRadius=10).
Rectangle ne se clippe pas sur le parent ⇒ on utilisait un visuel
qui dépassait les coins. -->
<Border Grid.Column="0" CornerRadius="9,0,0,9">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{StaticResource Brush.Status.Installed}" />
<Style.Triggers>
<DataTrigger Binding="{Binding FeaturedVersion.IsRemoteOnly}" Value="True">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Available}" />
<Setter Property="Background" Value="{StaticResource Brush.Status.Available}" />
</DataTrigger>
<DataTrigger Binding="{Binding FeaturedVersion.IsBusy}" Value="True">
<Setter Property="Fill" Value="{StaticResource Brush.Status.Busy}" />
<Setter Property="Background" Value="{StaticResource Brush.Status.Busy}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Rectangle.Style>
</Rectangle>
</Border.Style>
</Border>
<Grid Grid.Column="1" Margin="32,28">
<Grid.ColumnDefinitions>
@@ -352,16 +510,42 @@
<!-- Label "Version courante" -->
<TextBlock Grid.Row="0" Grid.Column="0"
Text="VERSION COURANTE"
Text="{x:Static loc:Strings.FeaturedCurrent}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,4" />
<!-- Numéro version + badge -->
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='Proserve v{0}'}"
<TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='PROSERVE v{0}'}"
FontSize="32" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Primary}" />
<!-- Badge BÊTA : pill orange à côté de la version. Visible
uniquement quand isBeta=true dans le manifest. Tooltip
dynamique avec les notes des testeurs s'il y en a. -->
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
VerticalAlignment="Center"
Background="#F59E0B"
ToolTip="{Binding FeaturedVersion.BetaTooltip}"
Visibility="{Binding FeaturedVersion.IsBeta, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
FontSize="11" FontWeight="Bold"
Foreground="#1A0A00" />
</Border>
<!-- Badge CHANNEL : pill violette. Visible sur les entrées
manifest taggées avec un channel non-default (firefighter,
police, etc.) quand une autre row partage le numéro de
version. Violet #8B5CF6 pour se distinguer du orange BETA
et du bleu du statut "Available". -->
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
VerticalAlignment="Center"
Background="#8B5CF6"
ToolTip="{Binding FeaturedVersion.ChannelBadge}"
Visibility="{Binding FeaturedVersion.HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding FeaturedVersion.ChannelBadge}"
FontSize="11" FontWeight="Bold"
Foreground="White" />
</Border>
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
VerticalAlignment="Center">
<Border.Style>
@@ -387,28 +571,59 @@
FontSize="13"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,8,0,0">
<Run Text="Sortie le " />
<Run Text="{x:Static loc:Strings.ReleasedOn}" />
<Run Text="{Binding FeaturedVersion.PrimaryDate, Mode=OneWay}" />
<Run Text=" • " />
<Run Text="{Binding FeaturedVersion.SizeDisplay, Mode=OneWay}" />
</TextBlock>
<!-- Action principale (gros bouton) -->
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Style="{StaticResource PrimaryButton}"
Content="▶ LANCER"
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Orientation="Horizontal" VerticalAlignment="Center"
Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}">
<!-- Bouton AUTO featured (taille assortie au gros LANCER) -->
<Button Content="{x:Static loc:Strings.ActionAuto}"
FontSize="16" FontWeight="Bold"
Padding="20,16"
Margin="0,0,12,0"
Command="{Binding FeaturedVersion.ToggleAutoModeCommand}"
Visibility="{Binding FeaturedVersion.ShowAutoButton, Converter={StaticResource BoolToVisibility}}">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource SecondaryButton}">
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipInactive}" />
<Style.Triggers>
<DataTrigger Binding="{Binding FeaturedVersion.IsAutoModeSelected}" Value="True">
<Setter Property="Background" Value="#F5A623" />
<Setter Property="Foreground" Value="White" />
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipActive}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button Style="{StaticResource PrimaryButton}"
Content="{x:Static loc:Strings.ActionLaunchBig}"
FontSize="20" Padding="56,16"
VerticalAlignment="Center"
Command="{Binding FeaturedVersion.LaunchCommand}"
Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}" />
Command="{Binding FeaturedVersion.LaunchCommand}" />
</StackPanel>
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Style="{StaticResource AccentButton}"
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Orientation="Vertical" VerticalAlignment="Center"
Visibility="{Binding FeaturedVersion.IsRemoteOnly, Converter={StaticResource BoolToVisibility}}">
<Button Style="{StaticResource AccentButton}"
Content="{Binding FeaturedVersion.InstallButtonLabel}"
FontSize="20" Padding="48,16"
VerticalAlignment="Center"
Command="{Binding FeaturedVersion.InstallCommand}"
Visibility="{Binding FeaturedVersion.IsRemoteOnly, Converter={StaticResource BoolToVisibility}}" />
Command="{Binding FeaturedVersion.InstallCommand}" />
<!-- Bouton rouge « Annuler » : visible uniquement quand un partial existe.
Permet d'abandonner et repartir de zéro (confirmation requise). -->
<Button Style="{StaticResource DangerButton}"
Content="{x:Static loc:Strings.ActionCancel}"
FontSize="13" Padding="20,8" Margin="0,8,0,0"
HorizontalAlignment="Center"
Command="{Binding FeaturedVersion.RestartFromZeroCommand}"
ToolTip="{x:Static loc:Strings.MenuRestartFromZero}"
Visibility="{Binding FeaturedVersion.ShowRestartFromZero, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Orientation="Vertical" VerticalAlignment="Center" Width="220"
@@ -438,13 +653,18 @@
Click="OnMoreMenuClick">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="📜 Voir les release notes"
<MenuItem Header="{x:Static loc:Strings.MenuReleaseNotes}"
Command="{Binding PlacementTarget.Tag.ShowReleaseNotesCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<MenuItem Header="📁 Ouvrir le dossier"
<MenuItem Header="{x:Static loc:Strings.MenuOpenFolder}"
Command="{Binding PlacementTarget.Tag.OpenFolderCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="🗑 Supprimer cette version…"
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
</ContextMenu>
@@ -461,7 +681,7 @@
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="AUTRES VERSIONS"
<TextBlock Grid.Column="0" Text="{x:Static loc:Strings.SectionOther}"
FontSize="12" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
VerticalAlignment="Center" />
@@ -492,13 +712,168 @@
<Button HorizontalAlignment="Left" VerticalAlignment="Bottom"
Margin="32,0,0,24"
Style="{StaticResource SecondaryButton}"
Content="🔄 Vérifier les MAJ"
Content="{x:Static loc:Strings.TopBarCheckUpdates}"
Command="{Binding CheckForUpdatesCommand}" />
<!-- Copyright flottant en bas centré, sur une pill sombre pour rester
lisible quel que soit l'arrière-plan (image, voile noir, etc.).
Bottom margin volontairement faible pour rester collé au footer
(barre de DL) quand il s'affiche. -->
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
Margin="0,0,0,8"
Background="#A0000000"
CornerRadius="10"
Padding="10,4">
<TextBlock Text="{x:Static loc:Strings.Copyright}"
FontSize="11"
Foreground="White" />
</Border>
</Grid>
<!-- ====================== END LIBRARY PAGE ====================== -->
<!-- ====================== REPORT PAGE (WebView2) ====================== -->
<Grid Background="{StaticResource Brush.Bg.Window}"
Visibility="{Binding IsReport, Converter={StaticResource BoolToVisibility}}">
<wv2:WebView2 x:Name="ReportWebView"
Source="{Binding ReportUri, Mode=OneWay}" />
</Grid>
<!-- Footer : visible uniquement quand busy / status à afficher.
Le bouton "Vérifier les MAJ" a été déplacé en flottant au-dessus du body. -->
<Border Grid.Row="2" Background="{StaticResource Brush.Bg.Footer}"
<!-- ====================== DOCUMENTATION PAGE ====================== -->
<Grid Background="{StaticResource Brush.Bg.Window}"
Visibility="{Binding IsDocumentation, Converter={StaticResource BoolToVisibility}}">
<!-- WebView2 si une URL est configurée, sinon un placeholder explicatif -->
<wv2:WebView2 x:Name="DocsWebView"
Source="{Binding DocumentationUri, Mode=OneWay}"
Visibility="{Binding HasDocumentationUrl, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{x:Static loc:Strings.DocsPlaceholder}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="14" TextAlignment="Center" TextWrapping="Wrap"
MaxWidth="500"
Visibility="{Binding HasDocumentationUrl, Converter={StaticResource InverseBoolToVisibility}}" />
</Grid>
</Grid>
<!-- ============== END Content area ============== -->
<!-- ============== Left sidebar : nav buttons ==============
Row 1 + RowSpan=3 → la sidebar s'étend du bas du top bar jusqu'au
bas de la fenêtre, couvrant les 3 rows (health banner, content,
footer). Visuellement la sidebar reste le « chapeau de tout »
côté gauche, peu importe ce qui se passe à droite. -->
<Border Grid.Row="1" Grid.RowSpan="3" Grid.Column="0"
Background="#0A0A0E"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="0,0,1,0">
<DockPanel LastChildFill="True">
<!-- Bloc d'info en bas de sidebar : version du launcher + IP locale.
Utilité diagnostic terrain : le support peut lire les deux infos
sans avoir à naviguer dans Windows ou About. DockPanel.Dock=Bottom
pour rester collé au bas peu importe la hauteur de la fenêtre. -->
<Border DockPanel.Dock="Bottom"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="0,1,0,0"
Padding="16,10">
<!-- Grid 2 colonnes : la colonne label (Auto) prend la largeur du
plus long ("PS_Launcher:"), la colonne valeur démarre au même X
sur les deux lignes → version et IP visuellement alignées. -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0"
Text="PS_Launcher"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,0,8,0"
ToolTip="Version du launcher" />
<TextBlock Grid.Row="0" Grid.Column="1"
Text="{Binding AppVersion}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" />
<TextBlock Grid.Row="1" Grid.Column="0"
Text="IP"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="IPv4 locale du PC (interface avec gateway par défaut)" />
<TextBlock Grid.Row="1" Grid.Column="1"
Text="{Binding LocalIpAddress}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
<!-- Statut LAN P2P, refresh auto toutes les 10 s par DispatcherTimer
dans MainViewModel. ON / OFF (—) / KO (config ON mais bind échoué). -->
<TextBlock Grid.Row="2" Grid.Column="0"
Text="Serveur"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="État du serveur LAN P2P (partage des ZIPs aux peers du LAN)" />
<TextBlock Grid.Row="2" Grid.Column="1"
Text="{Binding LanServerStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0"
Text="Client"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="Mode client : tente d'aller chercher les ZIPs sur le LAN avant OVH" />
<TextBlock Grid.Row="3" Grid.Column="1"
Text="{Binding LanClientStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0"
Text="Peers"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="Nombre de peers LAN découverts auto via UDP (TTL 60 s)" />
<TextBlock Grid.Row="4" Grid.Column="1"
Text="{Binding LanPeerCount}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
</Grid>
</Border>
<StackPanel Orientation="Vertical" Margin="0,16,0,0">
<!-- Style des boutons définis dans Theme.xaml (NavButton) -->
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateLibraryCommand}"
Tag="{Binding IsLibrary}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📚" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavLibrary}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateReportCommand}"
Tag="{Binding IsReport}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📊" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavReport}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateDocumentationCommand}"
Tag="{Binding IsDocumentation}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📖" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavDocumentation}" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</DockPanel>
</Border>
<!-- Footer : Row 3, Col 1 → cantonné à la colonne content, sous la zone
Library/Report/Doc. La sidebar (RowSpan=2 sur rows 2-3) le contourne
par sa colonne. -->
<Border Grid.Row="3" Grid.Column="1"
Background="{StaticResource Brush.Bg.Footer}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
Padding="20,10"
Visibility="{Binding FooterVisibility}">
@@ -518,10 +893,10 @@
</StackPanel>
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="Annuler"
Content="{x:Static loc:Strings.ActionCancel}"
VerticalAlignment="Center" Margin="12,0,0,0"
Command="{Binding CancelDownloadCommand}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
Visibility="{Binding IsDownloadActive, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border>
</Grid>

View File

@@ -1,14 +1,164 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Interop;
using Microsoft.Web.WebView2.Wpf;
using PSLauncher.App.ViewModels;
using PSLauncher.Core.Localization;
namespace PSLauncher.App.Views;
public partial class MainWindow : Window
{
private static readonly int WM_PSLAUNCHER_BRING = RegisterWindowMessage("PSLAUNCHER_BRING_TO_FRONT");
[DllImport("user32.dll")]
private static extern int RegisterWindowMessage(string lpString);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
private const int SW_RESTORE = 9;
// ===== WM_GETMINMAXINFO : contraint la taille en mode Maximized à la work area =====
// Avec WindowStyle="None" + WindowChrome, Windows maximise la fenêtre à la taille
// PHYSIQUE du moniteur (incluant la zone de la taskbar), ce qui fait passer le footer
// sous la barre des tâches → barre de DL invisible. Le fix canonique est de hooker
// WM_GETMINMAXINFO et de remplacer ptMaxSize/ptMaxPosition par les coordonnées de
// la work area (rcWork) du moniteur sur lequel la fenêtre se trouve. Multi-écran-safe
// (gère même les taskbars sur écran secondaire) et insensible au DPI.
private const int WM_GETMINMAXINFO = 0x0024;
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr handle, uint flags);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)]
private struct MINMAXINFO
{
public POINT ptReserved;
public POINT ptMaxSize;
public POINT ptMaxPosition;
public POINT ptMinTrackSize;
public POINT ptMaxTrackSize;
}
[StructLayout(LayoutKind.Sequential)]
private struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
private static void HandleGetMinMaxInfo(IntPtr hwnd, IntPtr lParam)
{
var monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if (monitor == IntPtr.Zero) return;
var info = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
if (!GetMonitorInfo(monitor, ref info)) return;
var mmi = Marshal.PtrToStructure<MINMAXINFO>(lParam);
var work = info.rcWork;
var monitorRect = info.rcMonitor;
mmi.ptMaxPosition.X = Math.Abs(work.Left - monitorRect.Left);
mmi.ptMaxPosition.Y = Math.Abs(work.Top - monitorRect.Top);
mmi.ptMaxSize.X = Math.Abs(work.Right - work.Left);
mmi.ptMaxSize.Y = Math.Abs(work.Bottom - work.Top);
Marshal.StructureToPtr(mmi, lParam, true);
}
public MainWindow()
{
InitializeComponent();
SourceInitialized += MainWindow_SourceInitialized;
Closing += MainWindow_Closing;
// Force un reload de la WebView quand son onglet redevient visible
// (Library → Report ou Library → Doc), pour que l'utilisateur voie
// toujours les données les plus à jour côté serveur PHP. Sans ça,
// WebView2 garde la page en cache de la première navigation et ne
// refresh jamais. Au tout premier affichage CoreWebView2 n'est pas
// encore initialisée — le binding Source fait alors la nav initiale,
// les reloads suivants kicker uniquement sur les changements d'onglet.
ReportWebView.IsVisibleChanged += OnWebViewBecameVisible;
DocsWebView.IsVisibleChanged += OnWebViewBecameVisible;
}
private static void OnWebViewBecameVisible(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.NewValue is true && sender is WebView2 wv && wv.CoreWebView2 is not null)
{
wv.CoreWebView2.Reload();
}
}
/// <summary>
/// Si un DL est en cours, demande confirmation avant de fermer le launcher.
/// La progression est de toute façon préservée pour reprise au prochain lancement,
/// mais on évite à l'utilisateur de cliquer ✕ par accident en plein milieu d'un 14 Go.
/// </summary>
private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
if (DataContext is MainViewModel vm && vm.HasActiveDownload)
{
var version = vm.ActiveDownloadVersion ?? "?";
var result = ThemedMessageBox.Show(
Strings.MsgQuitWhileDownloadingConfirm(version),
Strings.MsgBoxQuit,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (result != MessageBoxResult.Yes) e.Cancel = true;
}
}
/// <summary>
/// Hook le pump de messages Windows pour réagir au broadcast envoyé par une
/// seconde instance qui veut nous mettre au premier plan.
/// </summary>
private void MainWindow_SourceInitialized(object? sender, EventArgs e)
{
var helper = new WindowInteropHelper(this);
var source = HwndSource.FromHwnd(helper.Handle);
source?.AddHook(WndProc);
}
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg == WM_PSLAUNCHER_BRING)
{
if (WindowState == WindowState.Minimized)
WindowState = WindowState.Normal;
else
ShowWindow(hwnd, SW_RESTORE);
Activate();
SetForegroundWindow(hwnd);
handled = true;
}
else if (msg == WM_GETMINMAXINFO)
{
HandleGetMinMaxInfo(hwnd, lParam);
// On laisse handled=false : Windows continue son traitement avec le MINMAXINFO
// qu'on vient de réécrire (clamp à la work area du moniteur).
}
return IntPtr.Zero;
}
/// <summary>

View File

@@ -102,6 +102,24 @@ internal static class MarkdownTheming
foreach (var i in h.Inlines.ToList())
ApplyToInline(i, accent, fgSecondary, accent);
break;
case Run r:
// Markdig.Wpf rend `inline code` en tant que <Run> avec un Style
// par défaut qui pose un Background clair + Foreground "fancy" — illisible
// sur notre thème sombre. On détecte le code inline par sa FontFamily
// (monospace appliquée par le style Markdig) et on rebadge avec des couleurs
// qui matchent le reste de la UI : fond noir foncé + texte primaire.
var fontFamilyName = r.FontFamily?.Source ?? string.Empty;
if (fontFamilyName.Contains("Consolas", StringComparison.OrdinalIgnoreCase)
|| fontFamilyName.Contains("Courier", StringComparison.OrdinalIgnoreCase)
|| fontFamilyName.Contains("Typewriter", StringComparison.OrdinalIgnoreCase))
{
r.Background = new SolidColorBrush(Color.FromRgb(0x1A, 0x1F, 0x2A));
r.Foreground = new SolidColorBrush(Color.FromRgb(0xCD, 0xD6, 0xE4));
r.FontFamily = new FontFamily("Cascadia Code, Consolas, monospace");
}
break;
case Span s:
foreach (var i in s.Inlines.ToList())
ApplyToInline(i, fg, fgSecondary, accent);

View File

@@ -1,7 +1,9 @@
<Window x:Class="PSLauncher.App.Views.OnboardingDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Activation PROSERVE Launcher"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.OnboardingTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="540" Height="420"
MinWidth="480" MinHeight="380"
WindowStartupLocation="CenterOwner"
@@ -17,17 +19,17 @@
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Activer votre license"
<TextBlock Grid.Row="0" Text="{x:Static loc:Strings.OnboardingHeading}"
FontSize="22" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}" />
<TextBlock Grid.Row="1"
Text="Saisis la clé fournie par ASTERION. Elle te donne accès aux téléchargements jusqu'à la date de validité associée."
Text="{x:Static loc:Strings.OnboardingBody}"
TextWrapping="Wrap"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,8,0,18" />
<TextBlock Grid.Row="2" Text="Clé de license"
<TextBlock Grid.Row="2" Text="{x:Static loc:Strings.OnboardingLicenseKey}"
FontSize="12" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}" />
<TextBox Grid.Row="3" x:Name="KeyBox"
@@ -51,12 +53,12 @@
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="Plus tard"
Content="{x:Static loc:Strings.ActionLater}"
IsCancel="True"
Margin="0,0,12,0"
Click="OnLater" />
<Button Style="{StaticResource AccentButton}"
Content="Activer"
Content="{x:Static loc:Strings.OnboardingActivate}"
Padding="32,10"
IsDefault="True"
Click="OnActivate"

View File

@@ -1,6 +1,7 @@
using System.Windows;
using System.Windows.Media;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
namespace PSLauncher.App.Views;
@@ -24,12 +25,12 @@ public partial class OnboardingDialog : Window
var key = KeyBox.Text?.Trim().ToUpperInvariant() ?? string.Empty;
if (string.IsNullOrEmpty(key) || key.Contains("XXXX"))
{
ShowStatus("Saisis une clé valide au format PRSRV-XXXX-XXXX-XXXX-XXXX.", isError: true);
ShowStatus(Strings.OnboardingInvalidKey, isError: true);
return;
}
ActivateButton.IsEnabled = false;
ShowStatus("Validation en cours…", isError: false);
ShowStatus(Strings.OnboardingValidating, isError: false);
try
{
@@ -38,7 +39,8 @@ public partial class OnboardingDialog : Window
{
case "valid":
_licenseService.SaveCached(key, resp);
ShowStatus($"License activée ({resp.OwnerName}). Téléchargements autorisés jusqu'au {resp.DownloadEntitlementUntil:dd/MM/yyyy}.", isError: false);
ShowStatus(Strings.OnboardingActivated(resp.OwnerName ?? "—",
Strings.FormatDate(resp.DownloadEntitlementUntil)), isError: false);
LicenseActivated = true;
DialogResult = true;
Close();
@@ -46,27 +48,27 @@ public partial class OnboardingDialog : Window
case "expired":
_licenseService.SaveCached(key, resp); // on cache quand même pour permettre de lancer les versions installées
ShowStatus($"License expirée le {resp.DownloadEntitlementUntil:dd/MM/yyyy}. Tu peux toujours utiliser les versions déjà installées, mais plus en télécharger de nouvelles.", isError: true);
ShowStatus(Strings.OnboardingExpired(Strings.FormatDate(resp.DownloadEntitlementUntil)), isError: true);
LicenseActivated = true;
DialogResult = true;
Close();
return;
case "revoked":
ShowStatus("Cette license a été révoquée. Contacte ASTERION.", isError: true);
ShowStatus(Strings.OnboardingRevoked, isError: true);
break;
case "machine_limit_exceeded":
ShowStatus(resp.Message ?? "Cette license a atteint son nombre maximum de machines.", isError: true);
ShowStatus(resp.Message ?? Strings.OnboardingMachineLimit, isError: true);
break;
case "invalid":
default:
ShowStatus(resp.Message ?? "Clé de license inconnue.", isError: true);
ShowStatus(resp.Message ?? Strings.OnboardingInvalid, isError: true);
break;
}
}
catch (Exception ex)
{
ShowStatus($"Erreur de communication avec le serveur :\n{ex.Message}", isError: true);
ShowStatus(Strings.OnboardingServerError(ex.Message), isError: true);
}
finally
{

View File

@@ -1,7 +1,9 @@
<Window x:Class="PSLauncher.App.Views.ReleaseNotesViewerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Notes de version"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.MsgBoxReleaseNotes}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="640" Height="540"
MinWidth="480" MinHeight="400"
WindowStartupLocation="CenterOwner"
@@ -32,7 +34,7 @@
<Button Grid.Row="2"
Style="{StaticResource SecondaryButton}"
Content="Fermer"
Content="{x:Static loc:Strings.ActionClose}"
IsCancel="True"
IsDefault="True"
HorizontalAlignment="Right"

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,10 @@
using System;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Extensions.DependencyInjection;
using PSLauncher.App.ViewModels;
using PSLauncher.Core.Security;
namespace PSLauncher.App.Views;
@@ -25,4 +30,80 @@ public partial class SettingsDialog : Window
DialogResult = false;
Close();
}
/// <summary>
/// Quand l'opérateur clique pour déplier les paramètres avancés, on vérifie le
/// settings lock service. Si verrouillé, on affiche le password prompt. Si Cancel
/// ou mauvais mot de passe, on referme l'expander pour que la section reste cachée.
///
/// Trace écrite dans un fichier dédié (settings-lock.log) à chaque appel pour
/// permettre de diagnostiquer les cas "l'expander se déplie pas" sans dépendre
/// du Serilog principal (au cas où l'event ne traverserait même pas le code).
/// </summary>
private void OnAdvancedExpanded(object sender, RoutedEventArgs e)
{
TraceExpanded("OnAdvancedExpanded fired");
if (sender is not Expander exp)
{
TraceExpanded("sender is not Expander, aborting");
return;
}
// Résout le service depuis l'App DI container. Service-locator pattern toléré
// ici car SettingsDialog n'est pas injectable directement (instancié par new).
var app = (App)Application.Current;
var lockService = app.HostServices?.GetService<ISettingsLockService>();
if (lockService is null)
{
TraceExpanded("lockService is null, expander stays open");
return;
}
TraceExpanded($"lockService.HasLockConfigured={lockService.HasLockConfigured} IsLocked={lockService.IsLocked}");
if (!lockService.IsLocked)
{
TraceExpanded("not locked, expander stays open");
return;
}
TraceExpanded("locked, showing password dialog");
bool? ok = null;
try
{
var dialog = new SettingsLockDialog(lockService) { Owner = this };
ok = dialog.ShowDialog();
TraceExpanded($"dialog result: {ok}");
}
catch (Exception ex)
{
// Toute exception (XAML parse error, missing resource, etc.) doit être
// logguée et NE PAS rester silencieuse. Sans ce catch, le bug se manifeste
// par "rien ne s'ouvre, mais le log dit que ça devrait" (cas 0.28.2 avec
// Brush.Status.Danger inexistant).
TraceExpanded($"EXCEPTION creating/showing SettingsLockDialog: {ex.GetType().Name}: {ex.Message}");
// On laisse l'expander DÉPLIÉ (ok=null tombera dans le if false ci-dessous
// qui le refermerait). Plutôt : on garde déplié pour ne pas frustrer l'user
// si le lock dialog est cassé — il aura accès, et le bug doit être réglé.
return;
}
if (ok != true)
{
// Cancel ou mauvais mot de passe → on referme l'expander.
exp.IsExpanded = false;
}
}
private static void TraceExpanded(string msg)
{
try
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "logs");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "settings-lock.log");
File.AppendAllText(path,
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z {msg}{Environment.NewLine}");
}
catch { /* best effort */ }
}
}

View File

@@ -0,0 +1,64 @@
<Window x:Class="PSLauncher.App.Views.SettingsLockDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.SettingsLockedTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="520" Height="360"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Topmost="True"
ShowInTaskbar="False"
Background="{StaticResource Brush.Bg.Window}">
<Grid Margin="32,28">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
FontSize="18" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}">
<Run Text="🔒 " />
<Run Text="{x:Static loc:Strings.SettingsLockedTitle}" />
</TextBlock>
<TextBlock Grid.Row="1"
Text="{x:Static loc:Strings.SettingsLockedBody}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" TextWrapping="Wrap"
Margin="0,8,0,12" />
<PasswordBox Grid.Row="2"
x:Name="PasswordInput"
Background="#1A1A20"
Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
Padding="8" FontSize="14"
KeyDown="OnPasswordKeyDown" />
<TextBlock Grid.Row="3"
x:Name="ErrorText"
Foreground="#FCA5A5"
FontSize="11"
Margin="0,4,0,0"
Visibility="Collapsed"
Text="{x:Static loc:Strings.SettingsLockedWrongPassword}" />
<StackPanel Grid.Row="4" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.ActionCancel}" IsCancel="True"
Margin="0,0,12,0"
Click="OnCancel" />
<Button Style="{StaticResource AccentButton}"
Content="{x:Static loc:Strings.SettingsLockedUnlock}"
Padding="20,8"
IsDefault="True"
Click="OnUnlock" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,77 @@
using System.Windows;
using System.Windows.Input;
using PSLauncher.Core.Security;
namespace PSLauncher.App.Views;
/// <summary>
/// Dialog de saisie du password qui déverrouille la section Avancés des Settings.
/// Le password est hashé en SHA-256 et comparé au hash du manifest signé. L'unlock
/// est valable jusqu'à la fermeture du launcher (en-mémoire dans <see cref="ISettingsLockService"/>).
///
/// DialogResult :
/// <c>true</c> = unlock réussi
/// <c>false</c> = annulation (Cancel)
/// </summary>
public partial class SettingsLockDialog : Window
{
private readonly ISettingsLockService _lock;
public SettingsLockDialog(ISettingsLockService lockService)
{
InitializeComponent();
_lock = lockService;
Loaded += (_, _) =>
{
// Trace pour diagnostiquer un éventuel "le dialog ne s'affiche pas".
// Si on voit cette ligne dans settings-lock.log, le dialog est rendu
// → le pb est ailleurs (hors écran, derrière owner, etc.).
try
{
var dir = System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "logs");
System.IO.Directory.CreateDirectory(dir);
System.IO.File.AppendAllText(
System.IO.Path.Combine(dir, "settings-lock.log"),
$"{System.DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z SettingsLockDialog Loaded (visible={IsVisible}, left={Left}, top={Top}){System.Environment.NewLine}");
}
catch { }
Activate();
PasswordInput.Focus();
};
}
private void OnPasswordKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
TryUnlock();
e.Handled = true;
}
}
private void OnUnlock(object sender, RoutedEventArgs e) => TryUnlock();
private void TryUnlock()
{
var ok = _lock.TryUnlock(PasswordInput.Password);
if (ok)
{
DialogResult = true;
Close();
}
else
{
ErrorText.Visibility = Visibility.Visible;
PasswordInput.Clear();
PasswordInput.Focus();
}
}
private void OnCancel(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}

View File

@@ -0,0 +1,72 @@
<Window x:Class="PSLauncher.App.Views.ThemedMessageBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title=""
Icon="pack://application:,,,/Resources/favicon.ico"
SizeToContent="Height"
Width="480"
MinHeight="180"
MaxHeight="600"
ResizeMode="NoResize"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Brush.Bg.Window}">
<Border BorderBrush="{StaticResource Brush.Border}" BorderThickness="1">
<Grid Margin="24,20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Title -->
<TextBlock Grid.Row="0"
x:Name="TitleText"
FontSize="16" FontWeight="SemiBold"
Foreground="{StaticResource Brush.Text.Primary}"
Margin="0,0,0,12" />
<!-- Icon + Message -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
x:Name="IconText"
FontSize="36"
Margin="0,2,16,0"
VerticalAlignment="Top" />
<TextBlock Grid.Column="1"
x:Name="MessageText"
TextWrapping="Wrap"
FontSize="13" LineHeight="20"
Foreground="{StaticResource Brush.Text.Primary}"
VerticalAlignment="Center" />
</Grid>
<!-- Buttons -->
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,24,0,0">
<Button x:Name="Btn1"
Style="{StaticResource SecondaryButton}"
MinWidth="80" Padding="16,8" Margin="0,0,8,0"
Visibility="Collapsed"
Click="OnBtn1Click" />
<Button x:Name="Btn2"
Style="{StaticResource SecondaryButton}"
MinWidth="80" Padding="16,8" Margin="0,0,8,0"
Visibility="Collapsed"
Click="OnBtn2Click" />
<Button x:Name="Btn3"
Style="{StaticResource AccentButton}"
MinWidth="80" Padding="16,8"
Visibility="Collapsed"
Click="OnBtn3Click" />
</StackPanel>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,187 @@
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
using PSLauncher.Core.Localization;
namespace PSLauncher.App.Views;
/// <summary>
/// Drop-in pour <see cref="System.Windows.MessageBox"/> avec un look qui colle
/// au reste de la UI (dark theme, accent ASTERION). API copiée :
/// <code>
/// ThemedMessageBox.Show("Texte", "Titre", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
/// </code>
///
/// Notes :
/// - <see cref="MessageBoxImage"/> est mappé vers une icône emoji + couleur.
/// - Les libellés des boutons (OK / Yes / No / Cancel) sont localisés via <see cref="Strings"/>.
/// - Owner = MainWindow par défaut (centrage), peut être passé explicitement.
/// - Esc renvoie Cancel ou No selon le bouton défini comme cancel.
/// </summary>
public partial class ThemedMessageBox : Window
{
public MessageBoxResult Result { get; private set; } = MessageBoxResult.None;
private MessageBoxResult _btn1Result = MessageBoxResult.None;
private MessageBoxResult _btn2Result = MessageBoxResult.None;
private MessageBoxResult _btn3Result = MessageBoxResult.None;
private ThemedMessageBox(string message, string title, MessageBoxButton button,
MessageBoxImage icon, MessageBoxResult defaultResult)
{
InitializeComponent();
Title = title;
TitleText.Text = title;
MessageText.Text = message;
SetIcon(icon);
SetupButtons(button, defaultResult);
// Esc = cancel/no
PreviewKeyDown += (_, e) =>
{
if (e.Key == Key.Escape)
{
Result = button switch
{
MessageBoxButton.OK => MessageBoxResult.OK,
MessageBoxButton.OKCancel => MessageBoxResult.Cancel,
MessageBoxButton.YesNo => MessageBoxResult.No,
MessageBoxButton.YesNoCancel => MessageBoxResult.Cancel,
_ => MessageBoxResult.None,
};
DialogResult = false;
Close();
}
};
}
private void SetIcon(MessageBoxImage icon)
{
// NOTE : MessageBoxImage.Stop ≡ Error (16) et Asterisk ≡ Information (64)
// côté enum .NET, donc on ne switche que sur les noms canoniques.
switch (icon)
{
case MessageBoxImage.Error: // = Stop = Hand
IconText.Text = "⛔";
IconText.Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x44, 0x44));
break;
case MessageBoxImage.Warning: // = Exclamation
IconText.Text = "⚠";
IconText.Foreground = new SolidColorBrush(Color.FromRgb(0xF5, 0x9E, 0x0B));
break;
case MessageBoxImage.Question:
IconText.Text = "❓";
break;
case MessageBoxImage.Information: // = Asterisk
IconText.Text = "";
IconText.Foreground = new SolidColorBrush(Color.FromRgb(0x3B, 0x82, 0xF6));
break;
default:
IconText.Visibility = Visibility.Collapsed;
break;
}
}
/// <summary>
/// Configure jusqu'à 3 boutons. L'ordre visuel est gauche → droite, avec
/// le bouton "principal" (default) en accent à l'extrême droite. Les autres
/// en secondary à gauche.
/// </summary>
private void SetupButtons(MessageBoxButton button, MessageBoxResult defaultResult)
{
// Liste (label, result) dans l'ordre où on veut les afficher
var btns = button switch
{
MessageBoxButton.OK => new[] { (Strings.ActionOk, MessageBoxResult.OK) },
MessageBoxButton.OKCancel => new[] {
(Strings.ActionCancel, MessageBoxResult.Cancel),
(Strings.ActionOk, MessageBoxResult.OK),
},
MessageBoxButton.YesNo => new[] {
(Strings.ActionNo, MessageBoxResult.No),
(Strings.ActionYes, MessageBoxResult.Yes),
},
MessageBoxButton.YesNoCancel => new[] {
(Strings.ActionCancel, MessageBoxResult.Cancel),
(Strings.ActionNo, MessageBoxResult.No),
(Strings.ActionYes, MessageBoxResult.Yes),
},
_ => new[] { (Strings.ActionOk, MessageBoxResult.OK) },
};
// Btn3 = bouton accent (le plus à droite, principal). Toujours rempli.
// Btn2 = secondary à sa gauche, optionnel.
// Btn1 = secondary tout à gauche, optionnel.
// On remplit en partant de la fin pour que l'accent soit toujours sur Btn3.
var slots = new[] { Btn1, Btn2, Btn3 };
var resultSetters = new System.Action<MessageBoxResult>[]
{
r => _btn1Result = r,
r => _btn2Result = r,
r => _btn3Result = r,
};
int startSlot = 3 - btns.Length;
for (int i = 0; i < btns.Length; i++)
{
int slotIdx = startSlot + i;
slots[slotIdx].Content = btns[i].Item1;
slots[slotIdx].Visibility = Visibility.Visible;
resultSetters[slotIdx](btns[i].Item2);
// Default = celui dont le result match defaultResult
if (btns[i].Item2 == defaultResult)
slots[slotIdx].IsDefault = true;
}
// Si aucun match exact pour le default, on prend le bouton principal (Btn3)
if (Btn3.Visibility == Visibility.Visible && !Btn1.IsDefault && !Btn2.IsDefault && !Btn3.IsDefault)
Btn3.IsDefault = true;
}
private void OnBtn1Click(object sender, RoutedEventArgs e) => Finish(_btn1Result);
private void OnBtn2Click(object sender, RoutedEventArgs e) => Finish(_btn2Result);
private void OnBtn3Click(object sender, RoutedEventArgs e) => Finish(_btn3Result);
private void Finish(MessageBoxResult result)
{
Result = result;
DialogResult = true;
Close();
}
// ===================== STATIC SHOW OVERLOADS =====================
public static MessageBoxResult Show(string message)
=> Show(message, string.Empty, MessageBoxButton.OK, MessageBoxImage.None, MessageBoxResult.OK);
public static MessageBoxResult Show(string message, string title)
=> Show(message, title, MessageBoxButton.OK, MessageBoxImage.None, MessageBoxResult.OK);
public static MessageBoxResult Show(string message, string title, MessageBoxButton button)
=> Show(message, title, button, MessageBoxImage.None, DefaultFor(button));
public static MessageBoxResult Show(string message, string title, MessageBoxButton button, MessageBoxImage icon)
=> Show(message, title, button, icon, DefaultFor(button));
public static MessageBoxResult Show(string message, string title, MessageBoxButton button,
MessageBoxImage icon, MessageBoxResult defaultResult)
{
var owner = Application.Current?.Windows.OfType<Window>().FirstOrDefault(w => w.IsActive)
?? Application.Current?.MainWindow;
var dlg = new ThemedMessageBox(message, title, button, icon, defaultResult);
if (owner is not null && owner.IsVisible) dlg.Owner = owner;
else dlg.WindowStartupLocation = WindowStartupLocation.CenterScreen;
dlg.ShowDialog();
return dlg.Result;
}
private static MessageBoxResult DefaultFor(MessageBoxButton button) => button switch
{
MessageBoxButton.OK => MessageBoxResult.OK,
MessageBoxButton.OKCancel => MessageBoxResult.OK,
MessageBoxButton.YesNo => MessageBoxResult.Yes,
MessageBoxButton.YesNoCancel => MessageBoxResult.Yes,
_ => MessageBoxResult.None,
};
}

View File

@@ -1,7 +1,9 @@
<Window x:Class="PSLauncher.App.Views.UpdateAvailableDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Mise à jour disponible"
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="{x:Static loc:Strings.UpdateAvailableTitle}"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="640" Height="540"
MinWidth="480" MinHeight="400"
WindowStartupLocation="CenterOwner"
@@ -12,6 +14,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
@@ -35,14 +38,23 @@
Background="Transparent" />
</Border>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<CheckBox Grid.Row="3"
x:Name="PreserveSaveGamesCheckBox"
Content="{x:Static loc:Strings.UpdatePreserveSaveGames}"
ToolTip="{x:Static loc:Strings.UpdatePreserveSaveGamesTooltip}"
IsChecked="True"
Foreground="{StaticResource Brush.Text.Primary}"
Margin="0,14,0,0"
HorizontalAlignment="Left" />
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="Plus tard"
Content="{x:Static loc:Strings.ActionLater}"
IsCancel="True"
Margin="0,0,12,0"
Click="OnLater" />
<Button Style="{StaticResource PrimaryButton}"
Content="⬇ Télécharger"
Content="{x:Static loc:Strings.UpdateDownload}"
Padding="32,10"
Click="OnDownload" />
</StackPanel>

View File

@@ -1,4 +1,5 @@
using System.Windows;
using PSLauncher.Core.Localization;
using PSLauncher.Models;
namespace PSLauncher.App.Views;
@@ -9,19 +10,32 @@ public partial class UpdateAvailableDialog : Window
{
InitializeComponent();
var sizeText = version.Download.SizeBytes > 0
? Strings.FormatSize(version.Download.SizeBytes)
: Strings.SizeUnknown;
DataContext = new
{
Title = $"Proserve v{version.Version} disponible",
Subtitle = $"Date de release : {version.ReleasedAt.ToLocalTime():dd MMMM yyyy} • {FormatSize(version.Download.SizeBytes)}",
Title = Strings.UpdateAvailableHeading(version.Version),
Subtitle = Strings.UpdateAvailableSubtitle(Strings.FormatLongDate(version.ReleasedAt), sizeText),
ReleaseNotesDocument = MarkdownTheming.BuildThemedDocument(releaseNotesMarkdown)
};
}
public bool DownloadRequested { get; private set; }
/// <summary>
/// État de la case « Conserver les sauvegardes de la version précédente ».
/// Cochée par défaut, lue par MainViewModel APRÈS l'extraction du ZIP pour
/// décider s'il faut copier les .sav depuis l'install précédente vers la
/// nouvelle (voir CopyPreviousSaveGamesAsync).
/// </summary>
public bool PreserveSaveGames { get; private set; } = true;
private void OnDownload(object sender, RoutedEventArgs e)
{
DownloadRequested = true;
PreserveSaveGames = PreserveSaveGamesCheckBox.IsChecked == true;
DialogResult = true;
Close();
}
@@ -32,14 +46,4 @@ public partial class UpdateAvailableDialog : Window
DialogResult = false;
Close();
}
private static string FormatSize(long bytes)
{
if (bytes <= 0) return "taille inconnue";
string[] units = { "o", "Ko", "Mo", "Go", "To" };
double v = bytes;
int u = 0;
while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; }
return $"{v:0.#} {units[u]}";
}
}

View File

@@ -0,0 +1,222 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.ReportTool;
using PSLauncher.Models;
namespace PSLauncher.Core.ApiTool;
/// <summary>
/// Implémentation : copy récursif + atomic rename + backups horodatés.
/// Strictement parallèle à <see cref="ReportToolDeployer"/> mais avec
/// <c>_api/</c> comme source au lieu de <c>_report/</c>. Voir le commentaire
/// de cette dernière classe pour les détails du mécanisme.
/// </summary>
public sealed class ApiToolDeployer : IApiToolDeployer
{
private const string SourceSubdir = "_api";
private const string BackupSuffix = ".backup-";
private const string TimestampFormat = "yyyyMMdd-HHmmss";
private readonly Func<ApiToolConfig> _configProvider;
private readonly ILogger<ApiToolDeployer> _logger;
public ApiToolDeployer(
Func<ApiToolConfig> configProvider,
ILogger<ApiToolDeployer> logger)
{
_configProvider = configProvider;
_logger = logger;
}
public async Task<DeployResult> DeployAsync(
string installFolder,
IProgress<DeployProgress>? progress,
CancellationToken ct)
{
var cfg = _configProvider();
var source = Path.Combine(installFolder, SourceSubdir);
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
if (!Directory.Exists(source))
{
_logger.LogInformation("No {Sub}/ directory in {Folder}, skipping api deploy", SourceSubdir, installFolder);
return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target);
}
if (!Directory.Exists(cfg.HtdocsRoot))
{
// Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
try
{
Directory.CreateDirectory(cfg.HtdocsRoot);
_logger.LogWarning(
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
cfg.HtdocsRoot);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
}
}
var stagingTarget = target + ".new";
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
try
{
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList();
int total = allFiles.Count;
long bytesTotal = 0;
await Task.Run(() =>
{
Directory.CreateDirectory(stagingTarget);
int done = 0;
foreach (var srcFile in allFiles)
{
ct.ThrowIfCancellationRequested();
var rel = Path.GetRelativePath(source, srcFile);
var dstFile = Path.Combine(stagingTarget, rel);
var dstDir = Path.GetDirectoryName(dstFile);
if (!string.IsNullOrEmpty(dstDir) && !Directory.Exists(dstDir))
Directory.CreateDirectory(dstDir);
File.Copy(srcFile, dstFile, overwrite: true);
bytesTotal += new FileInfo(srcFile).Length;
done++;
progress?.Report(new DeployProgress(done, total, rel));
}
}, ct).ConfigureAwait(false);
if (Directory.Exists(target))
Directory.Move(target, backupTarget);
Directory.Move(stagingTarget, target);
PruneOldBackups(cfg);
_logger.LogInformation("Api tool deployed to {Target} ({Files} files, {Bytes} bytes), backup at {Backup}",
target, total, bytesTotal, backupTarget);
return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deploy api tool to {Target}", target);
try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); }
catch { /* ignore */ }
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
}
}
public Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct)
{
var cfg = _configProvider();
var prefix = cfg.FolderName + BackupSuffix;
if (!Directory.Exists(cfg.HtdocsRoot))
return Task.FromResult<IReadOnlyList<BackupInfo>>(Array.Empty<BackupInfo>());
var list = new List<BackupInfo>();
foreach (var dir in Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly))
{
ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(dir);
var tsPart = name.Substring(prefix.Length);
if (!DateTime.TryParseExact(tsPart, TimestampFormat, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ts))
{
continue;
}
long size = 0;
try
{
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
size += new FileInfo(f).Length;
}
catch { /* size best-effort */ }
list.Add(new BackupInfo(name, ts, size));
}
list.Sort((a, b) => b.TimestampUtc.CompareTo(a.TimestampUtc));
return Task.FromResult<IReadOnlyList<BackupInfo>>(list);
}
public async Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct)
{
var cfg = _configProvider();
var backupPath = Path.Combine(cfg.HtdocsRoot, backupFolderName);
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
if (!Directory.Exists(backupPath))
{
return new DeployResult(DeployStatus.BackupNotFound, 0, 0, target,
$"Le backup {backupFolderName} n'existe plus.");
}
var newBackupForCurrent = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
try
{
await Task.Run(() =>
{
ct.ThrowIfCancellationRequested();
if (Directory.Exists(target))
Directory.Move(target, newBackupForCurrent);
Directory.Move(backupPath, target);
}, ct).ConfigureAwait(false);
int files = 0;
long bytes = 0;
try
{
foreach (var f in Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories))
{
files++;
bytes += new FileInfo(f).Length;
}
}
catch { /* best-effort */ }
PruneOldBackups(cfg);
_logger.LogInformation("Reverted api to backup {Backup} ({Files} files), previous current saved as {New}",
backupFolderName, files, newBackupForCurrent);
return new DeployResult(DeployStatus.Deployed, files, bytes, target);
}
catch (Exception ex)
{
_logger.LogError(ex, "Api revert to {Backup} failed", backupFolderName);
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
}
}
private void PruneOldBackups(ApiToolConfig cfg)
{
try
{
var prefix = cfg.FolderName + BackupSuffix;
var dirs = Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly)
.Select(d => new { Path = d, Name = Path.GetFileName(d) })
.OrderByDescending(d => d.Name, StringComparer.Ordinal)
.ToList();
int keep = Math.Max(0, cfg.MaxBackups);
for (int i = keep; i < dirs.Count; i++)
{
try
{
Directory.Delete(dirs[i].Path, recursive: true);
_logger.LogDebug("Pruned old api backup {Dir}", dirs[i].Name);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not prune api backup {Dir} (will retry next deploy)", dirs[i].Name);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Api backup pruning skipped");
}
}
}

View File

@@ -0,0 +1,25 @@
using PSLauncher.Core.ReportTool;
namespace PSLauncher.Core.ApiTool;
/// <summary>
/// Déploie l'API PHP de stats PROSERVE depuis le sous-dossier <c>_api/</c> bundled
/// dans chaque ZIP PROSERVE vers le htdocs local. Mêmes garanties que
/// <see cref="IReportToolDeployer"/> : copy + atomic rename + backups horodatés
/// + revert manuel.
///
/// On réutilise les types <see cref="DeployResult"/>, <see cref="DeployStatus"/>,
/// <see cref="DeployProgress"/> et <see cref="BackupInfo"/> du namespace ReportTool
/// pour ne pas dupliquer la modélisation — le mécanisme est strictement le même.
/// </summary>
public interface IApiToolDeployer
{
Task<DeployResult> DeployAsync(
string installFolder,
IProgress<DeployProgress>? progress,
CancellationToken ct);
Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct);
Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct);
}

View File

@@ -65,17 +65,21 @@ public sealed class ConfigStore : IConfigStore
File.Move(tmp, _configFile, overwrite: true);
}
/// <summary>
/// Cible standard pour les déploiements clients : <c>C:\ASTERION_VR</c>.
/// Si un dossier ASTERION_VR existe déjà à côté de l'exe (mode dev / portable),
/// il est préféré pour ne pas casser l'environnement de développement.
/// </summary>
private static string ResolveDefaultInstallRoot()
{
// Mode dev / portable : ASTERION_VR à côté de l'exe (par ex. dans le repo)
var exeDir = AppContext.BaseDirectory;
var sibling = Path.Combine(Path.GetDirectoryName(exeDir.TrimEnd(Path.DirectorySeparatorChar))!, "ASTERION_VR");
var sibling = Path.Combine(
Path.GetDirectoryName(exeDir.TrimEnd(Path.DirectorySeparatorChar))!,
"ASTERION_VR");
if (Directory.Exists(sibling)) return sibling;
var dev = @"C:\ASTERION\GIT\PS_Launcher\ASTERION_VR";
if (Directory.Exists(dev)) return dev;
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"ASTERION_VR");
// Cible standard pour les clients (créée au premier install si absente)
return @"C:\ASTERION_VR";
}
}

View File

@@ -0,0 +1,222 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.ReportTool;
using PSLauncher.Models;
namespace PSLauncher.Core.DocTool;
/// <summary>
/// Implémentation : copy récursif + atomic rename + backups horodatés.
/// Strictement parallèle à <see cref="ReportToolDeployer"/> mais avec
/// <c>_doc/</c> comme source au lieu de <c>_report/</c>. Voir le commentaire
/// de cette dernière classe pour les détails du mécanisme.
/// </summary>
public sealed class DocToolDeployer : IDocToolDeployer
{
private const string SourceSubdir = "_doc";
private const string BackupSuffix = ".backup-";
private const string TimestampFormat = "yyyyMMdd-HHmmss";
private readonly Func<DocToolConfig> _configProvider;
private readonly ILogger<DocToolDeployer> _logger;
public DocToolDeployer(
Func<DocToolConfig> configProvider,
ILogger<DocToolDeployer> logger)
{
_configProvider = configProvider;
_logger = logger;
}
public async Task<DeployResult> DeployAsync(
string installFolder,
IProgress<DeployProgress>? progress,
CancellationToken ct)
{
var cfg = _configProvider();
var source = Path.Combine(installFolder, SourceSubdir);
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
if (!Directory.Exists(source))
{
_logger.LogInformation("No {Sub}/ directory in {Folder}, skipping doc deploy", SourceSubdir, installFolder);
return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target);
}
if (!Directory.Exists(cfg.HtdocsRoot))
{
// Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
try
{
Directory.CreateDirectory(cfg.HtdocsRoot);
_logger.LogWarning(
"HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.",
cfg.HtdocsRoot);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot);
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target,
$"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}");
}
}
var stagingTarget = target + ".new";
var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
try
{
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList();
int total = allFiles.Count;
long bytesTotal = 0;
await Task.Run(() =>
{
Directory.CreateDirectory(stagingTarget);
int done = 0;
foreach (var srcFile in allFiles)
{
ct.ThrowIfCancellationRequested();
var rel = Path.GetRelativePath(source, srcFile);
var dstFile = Path.Combine(stagingTarget, rel);
var dstDir = Path.GetDirectoryName(dstFile);
if (!string.IsNullOrEmpty(dstDir) && !Directory.Exists(dstDir))
Directory.CreateDirectory(dstDir);
File.Copy(srcFile, dstFile, overwrite: true);
bytesTotal += new FileInfo(srcFile).Length;
done++;
progress?.Report(new DeployProgress(done, total, rel));
}
}, ct).ConfigureAwait(false);
if (Directory.Exists(target))
Directory.Move(target, backupTarget);
Directory.Move(stagingTarget, target);
PruneOldBackups(cfg);
_logger.LogInformation("Doc tool deployed to {Target} ({Files} files, {Bytes} bytes), backup at {Backup}",
target, total, bytesTotal, backupTarget);
return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deploy doc tool to {Target}", target);
try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); }
catch { /* ignore */ }
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
}
}
public Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct)
{
var cfg = _configProvider();
var prefix = cfg.FolderName + BackupSuffix;
if (!Directory.Exists(cfg.HtdocsRoot))
return Task.FromResult<IReadOnlyList<BackupInfo>>(Array.Empty<BackupInfo>());
var list = new List<BackupInfo>();
foreach (var dir in Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly))
{
ct.ThrowIfCancellationRequested();
var name = Path.GetFileName(dir);
var tsPart = name.Substring(prefix.Length);
if (!DateTime.TryParseExact(tsPart, TimestampFormat, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ts))
{
continue;
}
long size = 0;
try
{
foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
size += new FileInfo(f).Length;
}
catch { /* size best-effort */ }
list.Add(new BackupInfo(name, ts, size));
}
list.Sort((a, b) => b.TimestampUtc.CompareTo(a.TimestampUtc));
return Task.FromResult<IReadOnlyList<BackupInfo>>(list);
}
public async Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct)
{
var cfg = _configProvider();
var backupPath = Path.Combine(cfg.HtdocsRoot, backupFolderName);
var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName);
if (!Directory.Exists(backupPath))
{
return new DeployResult(DeployStatus.BackupNotFound, 0, 0, target,
$"Le backup {backupFolderName} n'existe plus.");
}
var newBackupForCurrent = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture);
try
{
await Task.Run(() =>
{
ct.ThrowIfCancellationRequested();
if (Directory.Exists(target))
Directory.Move(target, newBackupForCurrent);
Directory.Move(backupPath, target);
}, ct).ConfigureAwait(false);
int files = 0;
long bytes = 0;
try
{
foreach (var f in Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories))
{
files++;
bytes += new FileInfo(f).Length;
}
}
catch { /* best-effort */ }
PruneOldBackups(cfg);
_logger.LogInformation("Reverted doc to backup {Backup} ({Files} files), previous current saved as {New}",
backupFolderName, files, newBackupForCurrent);
return new DeployResult(DeployStatus.Deployed, files, bytes, target);
}
catch (Exception ex)
{
_logger.LogError(ex, "Doc revert to {Backup} failed", backupFolderName);
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
}
}
private void PruneOldBackups(DocToolConfig cfg)
{
try
{
var prefix = cfg.FolderName + BackupSuffix;
var dirs = Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly)
.Select(d => new { Path = d, Name = Path.GetFileName(d) })
.OrderByDescending(d => d.Name, StringComparer.Ordinal)
.ToList();
int keep = Math.Max(0, cfg.MaxBackups);
for (int i = keep; i < dirs.Count; i++)
{
try
{
Directory.Delete(dirs[i].Path, recursive: true);
_logger.LogDebug("Pruned old doc backup {Dir}", dirs[i].Name);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not prune doc backup {Dir} (will retry next deploy)", dirs[i].Name);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Doc backup pruning skipped");
}
}
}

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

@@ -1,41 +1,72 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles;
using Polly;
using Polly.Retry;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Integrity;
using PSLauncher.Models;
namespace PSLauncher.Core.Downloads;
/// <summary>
/// Téléchargement HTTP avec :
/// - reprise par Range requests (If-Range sur ETag/Last-Modified) ;
/// - retry exponentiel via Polly (1-32 s, 6 tentatives, jitter) ;
/// - téléchargement parallèle multi-segments (config <see cref="LocalConfig.ParallelDownloadSegments"/>) ;
/// - vérif SHA-256 streaming en fin de DL ;
/// - persistance d'état (state.json) toutes les 5 s ou 100 Mo pour reprise après crash.
///
/// Le multi-segments contourne le throttling per-connection d'Apache (notamment OVH mutualisé)
/// : chaque segment ouvre une connexion TCP séparée et écrit à son offset dans le fichier
/// pré-alloué via <c>SetLength</c>. Speed-up typique observé : ×4 à ×10.
/// </summary>
public sealed class DownloadManager : IDownloadManager
{
private const int BufferSize = 1 << 20; // 1 MiB
private const int StateFlushBytesInterval = 100 * 1024 * 1024; // 100 MiB
private const int BufferSize = 4 << 20; // 4 MiB par segment (avant 1 MiB)
private const int StateFlushBytesInterval = 50 * 1024 * 1024; // 50 MiB
private const double StateFlushSecondsInterval = 5.0;
private const long MultiSegmentMinSize = 50 * 1024 * 1024; // < 50 MiB → single-segment
private readonly HttpClient _http;
private readonly IDownloadStateStore _stateStore;
private readonly IIntegrityService _integrity;
private readonly LocalConfig _config;
private readonly ILogger<DownloadManager> _logger;
private readonly ResiliencePipeline _retryPipeline;
// URL active partagée entre tous les segments d'un DL en cours, et lock pour
// refresher de façon coordonnée quand l'URL HMAC expire (TTL serveur = 6 h).
// Plusieurs segments peuvent recevoir 403 simultanément — un seul doit faire
// l'appel /api/download-url/.
private Uri _currentUrl = new("about:blank");
private DateTime _lastUrlRefresh = DateTime.MinValue;
private readonly SemaphoreSlim _urlRefreshLock = new(1, 1);
public DownloadManager(
HttpClient http,
IDownloadStateStore stateStore,
IIntegrityService integrity,
LocalConfig config,
ILogger<DownloadManager> logger)
{
_http = http;
_stateStore = stateStore;
_integrity = integrity;
_config = config;
_logger = logger;
_retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 6,
// 10 tentatives × backoff 1→32s avec jitter ≈ ~3 min de retry budget
// par segment. Couvre les outages PHP-FPM courts en multi-PC (où des
// segments peuvent être tués mid-stream par request_terminate_timeout)
// sans donner l'impression d'une boucle infinie en cas de panne réelle.
MaxRetryAttempts = 10,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(32),
@@ -60,28 +91,565 @@ public sealed class DownloadManager : IDownloadManager
public void DiscardResumableState(string version) => _stateStore.Discard(version);
public async Task<string> DownloadAsync(
public Task<string> DownloadAsync(
DownloadJob job,
IProgress<DownloadProgress>? progress,
CancellationToken ct)
=> DownloadAsync(job, progress, null, ct);
/// <summary>
/// Récupère l'URL active. Si la dernière récup date de moins de 5 s,
/// retourne celle en cache (évite que 8 segments en 403 simultanés
/// déclenchent 8 requêtes /api/download-url/). Sinon refresh via le
/// callback du job. Si pas de callback, retourne l'URL initiale du job.
/// </summary>
private async Task<Uri> GetOrRefreshUrlAsync(DownloadJob job, bool forceRefresh, CancellationToken ct)
{
await _urlRefreshLock.WaitAsync(ct).ConfigureAwait(false);
try
{
// Premier appel : initialise avec l'URL du job
if (_currentUrl.Scheme == "about")
{
_currentUrl = job.Url;
_lastUrlRefresh = DateTime.UtcNow;
}
// Si refresh forcé (un segment a vu 403), tente de fetcher une nouvelle URL.
// Debounce 5 s pour fusionner les refresh en rafale des 8 segments.
if (forceRefresh && (DateTime.UtcNow - _lastUrlRefresh).TotalSeconds >= 5
&& job.RefreshUrlAsync is not null)
{
_logger.LogInformation("Refreshing signed URL for v{Version} (URL expired or 403)", job.Version);
var fresh = await job.RefreshUrlAsync(ct).ConfigureAwait(false);
if (fresh is not null)
{
_currentUrl = fresh;
_lastUrlRefresh = DateTime.UtcNow;
_logger.LogInformation("Signed URL refreshed for v{Version}", job.Version);
}
}
return _currentUrl;
}
finally
{
_urlRefreshLock.Release();
}
}
public async Task<string> DownloadAsync(
DownloadJob job,
IProgress<DownloadProgress>? progress,
IProgress<double>? hashProgress,
CancellationToken ct)
{
// Reset URL state pour ce job (DownloadManager est singleton DI)
_currentUrl = job.Url;
_lastUrlRefresh = DateTime.UtcNow;
var partialPath = _stateStore.GetPartialPath(job.Version);
var finalPath = Path.ChangeExtension(partialPath, null); // strip ".partial" → .zip
// 1. Récupère l'état existant (s'il y en a un, et qu'il correspond à la même URL/sha)
// 1. Récupère l'état existant. On compare la ressource par son SHA-256 (signé via le
// manifest) plutôt que par URL : les URLs HMAC-signées changent à chaque appel
// (?exp=…&sig=…), donc une comparaison stricte d'URL invaliderait toujours le resume.
// On compare aussi le path (host+path sans query) pour détecter un changement de
// serveur/fichier, mais on ignore la querystring.
var existing = _stateStore.Load(job.Version);
if (existing is not null && (existing.Url != job.Url.ToString() || existing.ExpectedSha256 != job.ExpectedSha256))
if (existing is not null)
{
_logger.LogInformation("Cached state for {Version} mismatches new job (url/sha changed) — discarding", job.Version);
bool shaMismatch = !string.IsNullOrEmpty(job.ExpectedSha256)
&& !string.IsNullOrEmpty(existing.ExpectedSha256)
&& !string.Equals(existing.ExpectedSha256, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase);
bool pathMismatch = false;
if (Uri.TryCreate(existing.Url, UriKind.Absolute, out var oldUri))
{
pathMismatch = oldUri.Host != job.Url.Host || oldUri.AbsolutePath != job.Url.AbsolutePath;
}
if (shaMismatch || pathMismatch)
{
_logger.LogInformation("Cached state for {Version} mismatches new job (sha={Sha} path={Path}) — discarding",
job.Version, shaMismatch, pathMismatch);
_stateStore.Discard(job.Version);
existing = null;
}
else
{
// Même ressource : on rafraîchit juste l'URL stockée (utile pour les futures requêtes
// au sein de cette session, même si en pratique chaque segment requête via job.Url).
existing.Url = job.Url.ToString();
}
}
// 2. Choix single vs multi-segment
// Hard-cap à 32 : au-delà OVH mutualisé risque le rate-limit per-IP et le
// gain marginal devient négatif. 16 = défaut, 24-32 OK pour les serveurs
// qui le supportent. 1 = comportement single-segment legacy.
var requestedSegments = Math.Clamp(_config.ParallelDownloadSegments, 1, 32);
var useMultiSegment = requestedSegments > 1
&& job.ExpectedSize >= MultiSegmentMinSize
&& (existing is null || existing.Segments.Count > 0);
// Si l'état existant est en mode legacy (single-segment) et qu'on veut multi → on conserve le legacy
// pour pouvoir reprendre proprement (sinon on jetterait son DL).
if (existing is { Segments.Count: 0 } && File.Exists(partialPath))
{
_logger.LogInformation("Existing single-segment partial detected — staying single-segment for resume");
useMultiSegment = false;
}
// 3. Vérif espace disque (uniquement à l'initial, pas au resume)
if (existing is null && job.ExpectedSize > 0)
{
var drive = new DriveInfo(Path.GetPathRoot(_stateStore.GetDownloadsDirectory())!);
var needed = (long)(job.ExpectedSize * 1.05);
if (drive.AvailableFreeSpace < needed)
throw new IOException(
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
}
if (useMultiSegment)
{
return await DownloadMultiSegmentAsync(
job, partialPath, finalPath, existing, requestedSegments, progress, hashProgress, ct).ConfigureAwait(false);
}
return await DownloadSingleSegmentAsync(
job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false);
}
// ===================================================================
// ============== MULTI-SEGMENT (parallèle, fast path) ==============
// ===================================================================
private async Task<string> DownloadMultiSegmentAsync(
DownloadJob job,
string partialPath,
string finalPath,
DownloadState? existing,
int segmentCount,
IProgress<DownloadProgress>? progress,
IProgress<double>? hashProgress,
CancellationToken ct)
{
var swPhase = Stopwatch.StartNew();
// 1. On fait CONFIANCE au manifest : pas de HEAD probe.
// Le HEAD ajoutait jusqu'à 30 s-5 min de latence quand le serveur faisait
// un md5_file() sur 14 Go à chaque requête (vu sur OVH/gate.php). Le manifest
// est signé Ed25519 donc sa taille est de toute façon fiable. Si le serveur
// ne supporte pas Range, on le détecte sur le premier segment (200 au lieu
// de 206) et on fallback sur single-segment.
long total = job.ExpectedSize;
if (total <= 0)
{
_logger.LogInformation("ExpectedSize=0 — falling back to single-segment");
return await DownloadSingleSegmentAsync(job, partialPath, finalPath, existing, progress, hashProgress, ct).ConfigureAwait(false);
}
// 2. State (resume vs fresh)
DownloadState state;
if (existing is not null && existing.Segments.Count > 0 && existing.TotalBytes == total && File.Exists(partialPath))
{
state = existing;
_logger.LogInformation("Resuming multi-segment DL for v{Version} ({Segments} segments, {Done}/{Total} bytes)",
job.Version, state.Segments.Count, state.DownloadedBytes, state.TotalBytes);
}
else
{
// Discard tout résidu (state mismatch ou partial sans state)
_stateStore.Discard(job.Version);
state = new DownloadState
{
Version = job.Version,
Url = job.Url.ToString(),
TotalBytes = total,
ExpectedSha256 = job.ExpectedSha256,
PartialPath = partialPath,
StartedAtUtc = DateTime.UtcNow,
LastChunkAtUtc = DateTime.UtcNow,
Segments = BuildSegments(total, segmentCount),
};
_stateStore.Save(state);
}
// 3. Pré-allocation du fichier final.
// Stratégie : on tente de marquer le fichier en SPARSE (FSCTL_SET_SPARSE)
// avant le SetLength, ce qui rend l'opération métadata-only (~milliseconde
// au lieu de 5-30 s sur HDD à cause du zero-fill NTFS).
// Pas besoin d'admin (contrairement à SetFileValidData). Aucune fuite de
// données : les zones non écrites sont lues comme zéros virtuels par NTFS.
// Si FSCTL_SET_SPARSE échoue (volume FAT32 ou autre), fallback sur SetLength
// classique.
var swAlloc = Stopwatch.StartNew();
await Task.Run(() =>
{
using var alloc = new FileStream(partialPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite);
if (alloc.Length != total)
{
bool sparseOk = SparseFileSupport.TrySetSparse(alloc.SafeFileHandle);
_logger.LogInformation("Pre-allocating {Path} to {Bytes} bytes (sparse={Sparse})", partialPath, total, sparseOk);
alloc.SetLength(total);
}
}, ct).ConfigureAwait(false);
swAlloc.Stop();
_logger.LogInformation("Pre-alloc done in {Ms} ms (HEAD skipped, total phase {TotalMs} ms)", swAlloc.ElapsedMilliseconds, swPhase.ElapsedMilliseconds);
// Une fois la pré-alloc terminée, on émet immédiatement un progress 0/total
// pour que le footer passe de « Préparation… » à « ⬇ v1.4.6 : 0 / 14 Go ».
// La vraie progression suit ~250 ms après quand le reporter task tick.
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, null));
// 4. Lance les workers en parallèle.
// Compteur agrégé via Interlocked → pas de lock dans le hot path.
// Initialisé sur la somme des seg.DownloadedBytes DURABLES plutôt que sur
// state.DownloadedBytes (live aggregate persisté, qui peut être en avance
// de la réalité disque si pause survenue entre checkpoints). Sans ça, le
// footer afficherait "paused at X" mais segments resumeraient à un point
// en arrière, et l'aggregate finirait avec un drift cosmétique.
long aggregateBytes = state.Segments.Sum(s => s.DownloadedBytes);
// Action passée aux segments : juste un Add atomique (zéro contention).
void OnSegmentBytes(int _, long deltaBytes)
=> Interlocked.Add(ref aggregateBytes, deltaBytes);
// Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte.
// Tous les 5 s ou 50 Mo, persist le state.json.
// Tourne sur un thread séparé pour ne jamais être starvé par les workers DL.
//
// Calcul du débit : on utilise une FENÊTRE GLISSANTE de ~3 s (12 samples
// de 250ms) plutôt qu'un delta instantané sur le dernier tick. Pourquoi :
// avec un delta tick-à-tick, dès qu'un segment finit OU que Polly retry
// entre 2 tentatives OU qu'il y a un micro-stall réseau, le compteur ne
// bouge pas pendant 1-2 ticks → bps = 0 → ETA = null → UI cache les
// deux 250-500 ms → clignotement permanent à l'écran (symptôme rapporté).
// Avec la fenêtre, un trou de 1-2 ticks est dilué dans 12 samples → le
// débit affiché reste stable. La latence d'adaptation est ~3s, ce qui
// est largement OK visuellement (l'opérateur ne perçoit pas 3s de retard
// sur un chiffre qui de toute façon fluctue de ±5-10 % au cours d'un DL).
const int BpsWindowSize = 12; // 12 × 250 ms = 3 s
using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var reporterTask = Task.Run(async () =>
{
var sw = Stopwatch.StartNew();
var lastFlush = TimeSpan.Zero;
long lastFlushBytes = Interlocked.Read(ref aggregateBytes);
// Fenêtre glissante (time, bytes) pour lissage du débit.
var bpsWindow = new Queue<(TimeSpan At, long Bytes)>(BpsWindowSize + 1);
try
{
while (!reporterCts.IsCancellationRequested)
{
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
var snapshot = Interlocked.Read(ref aggregateBytes);
var elapsed = sw.Elapsed;
// Push le nouveau sample, évince le plus ancien si on dépasse.
bpsWindow.Enqueue((elapsed, snapshot));
while (bpsWindow.Count > BpsWindowSize) bpsWindow.Dequeue();
// bps = (bytes_now - bytes_oldest_in_window) / time_span_window
// Fallback à 0 tant qu'on n'a pas au moins 2 samples (le 1er tick).
double bps = 0;
if (bpsWindow.Count >= 2)
{
var oldest = bpsWindow.Peek();
var span = (elapsed - oldest.At).TotalSeconds;
if (span > 0) bps = (snapshot - oldest.Bytes) / span;
}
TimeSpan? eta = null;
if (bps > 0 && total > snapshot)
eta = TimeSpan.FromSeconds((total - snapshot) / bps);
progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
snapshot - lastFlushBytes >= StateFlushBytesInterval)
{
state.DownloadedBytes = snapshot;
state.LastChunkAtUtc = DateTime.UtcNow;
_stateStore.Save(state);
lastFlush = elapsed;
lastFlushBytes = snapshot;
}
}
}
catch (OperationCanceledException) { /* attendu en fin de DL */ }
}, reporterCts.Token);
var tasks = state.Segments
.Where(s => !s.Completed)
.Select(s => Task.Run(() =>
_retryPipeline.ExecuteAsync(async innerCt =>
{
await DownloadSegmentAsync(job, state, s, partialPath, OnSegmentBytes, innerCt).ConfigureAwait(false);
}, ct).AsTask(), ct))
.ToArray();
try
{
await Task.WhenAll(tasks).ConfigureAwait(false);
}
catch
{
reporterCts.Cancel();
try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ }
state.DownloadedBytes = Interlocked.Read(ref aggregateBytes);
_stateStore.Save(state);
throw;
}
// Stoppe le reporter et fais un dernier flush
reporterCts.Cancel();
try { await reporterTask.ConfigureAwait(false); } catch { /* ignore */ }
state.DownloadedBytes = Interlocked.Read(ref aggregateBytes);
_stateStore.Save(state);
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, TimeSpan.Zero));
// 5. Vérif complétion des segments
// Le check `actualSize != total` ci-dessous est INSUFFISANT car le fichier est
// pré-alloué en sparse à `total` octets — sa taille filesystem matchera toujours
// peu importe ce qui a été écrit. Si un segment a échoué silencieusement (cas
// pré-0.24.5 ou bug futur), on aurait des trous (zéros NTFS) → SHA KO en sortie
// sans diagnostic clair. On vérifie explicitement que CHAQUE segment a atteint
// sa cible AVANT de lancer le SHA-256 (qui prend 30s-3min sur un 14 Go).
var incomplete = state.Segments.FirstOrDefault(s => !s.Completed || s.DownloadedBytes < s.Length);
if (incomplete is not null)
{
_stateStore.Save(state); // garde l'état pour permettre un resume manuel
throw new InvalidDataException(
$"Segment {incomplete.Index} incomplet : {incomplete.DownloadedBytes:N0}/{incomplete.Length:N0} octets " +
$"(Completed={incomplete.Completed}). Relance le téléchargement pour reprendre.");
}
var actualSize = new FileInfo(partialPath).Length;
if (actualSize != total)
{
_stateStore.Discard(job.Version);
throw new InvalidDataException($"Taille téléchargée incorrecte : attendu {total}, obtenu {actualSize}");
}
// 6. Vérif SHA-256
await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false);
return finalPath;
}
private static List<DownloadSegment> BuildSegments(long total, int count)
{
var segments = new List<DownloadSegment>(count);
long segSize = total / count;
for (int i = 0; i < count; i++)
{
long start = i * segSize;
long end = (i == count - 1) ? total - 1 : (start + segSize - 1);
segments.Add(new DownloadSegment
{
Index = i,
Start = start,
End = end,
DownloadedBytes = 0,
Completed = false,
});
}
return segments;
}
private async Task DownloadSegmentAsync(
DownloadJob job,
DownloadState state,
DownloadSegment seg,
string partialPath,
Action<int, long> onBytes,
CancellationToken ct)
{
if (seg.Completed) return;
var segStart = seg.Start + seg.DownloadedBytes;
if (segStart > seg.End) { seg.Completed = true; return; }
// Récupère l'URL active (peut avoir été refreshée par un autre segment)
var url = await GetOrRefreshUrlAsync(job, forceRefresh: false, ct).ConfigureAwait(false);
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Range = new RangeHeaderValue(segStart, seg.End);
if (!string.IsNullOrEmpty(state.Etag))
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
if (resp.StatusCode == HttpStatusCode.OK)
{
// Le serveur a renvoyé tout au lieu du range : la ressource a probablement changé
// (ou il n'a en fait pas accepté le range). On signale un retry à blanc.
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
}
// 403 / 410 / 404 = URL HMAC expirée (gate.php renvoie 403 "Forbidden:
// expired"), OU le fichier a été renommé/supprimé côté serveur (cas
// typique : ré-upload du ZIP avec un nouveau nom pour invalider le cache
// OVH CDN). Dans les deux cas, refresh l'URL via le callback du job —
// l'endpoint /download-url/{version} renvoie la NOUVELLE URL pointant
// vers le fichier actuellement présent sur le serveur. Si refresh donne
// une URL DIFFÉRENTE de celle qu'on a utilisée dans la requête → retry
// avec la nouvelle. Sinon → c'est un vrai 404, on bubble.
//
// Note importante : on compare à `url` (la valeur utilisée dans la
// requête HTTP, capturée ligne 438 avant le send) plutôt qu'à un
// snapshot pris juste avant forceRefresh. Pourquoi : si 8 segments
// tombent en 404 simultanément, le PREMIER refresh met _currentUrl à
// jour, les autres voient le debounce 5s (no-op). Comparer à `url`
// (= URL réellement utilisée par CETTE requête, OLD) détecte
// correctement que _currentUrl est maintenant NEW → retry. Si on
// comparait à un snapshot pré-refresh, on raterait ce cas et on jetterait
// un faux 404 fatal sur tous les segments sauf le premier.
bool isUrlRefreshTrigger = resp.StatusCode == HttpStatusCode.Forbidden
|| resp.StatusCode == HttpStatusCode.Gone
|| resp.StatusCode == HttpStatusCode.NotFound
|| resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable;
if (isUrlRefreshTrigger && job.RefreshUrlAsync is not null)
{
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
bool urlChanged = !Uri.Equals(url, _currentUrl);
if (urlChanged)
{
_logger.LogInformation(
"Got HTTP {Status} on segment {Seg}, URL refreshed (old={Old} → new={New}), retrying",
(int)resp.StatusCode, seg.Index, url, _currentUrl);
throw new HttpResumableException(
$"HTTP {(int)resp.StatusCode} on segment {seg.Index}, URL refreshed",
isTransient: true);
}
// URL unchanged → soit le serveur insiste sur la même URL morte,
// soit le refresh a fallback sur le manifest cached (donc stale).
// 404 non-transient pour arrêter Polly et remonter une erreur claire
// qui guide l'opérateur vers « Vérifier les MAJ ».
if (resp.StatusCode == HttpStatusCode.NotFound)
{
throw new HttpResumableException(
$"HTTP 404 on segment {seg.Index} — le fichier a été renommé/supprimé côté serveur et le manifest local est obsolète. Clique « Vérifier les MAJ » avant de réessayer.",
isTransient: false);
}
// 416 unchanged URL = vrai mismatch de taille. On fait un HEAD probe
// pour obtenir la taille RÉELLE côté serveur et la rapporter dans le
// message d'erreur — l'opérateur saura ainsi s'il a un upload SFTP
// tronqué (file plus petit que prévu) ou un manifest qui ment sur
// sizeBytes. Cas concret : SFTP coupe au milieu d'un upload de 13 Go
// → le file fait 7 Go sur disque, mais le manifest dit 13 Go.
// Segment N qui demande bytes 9G-10G reçoit 416 (out of range).
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
{
long? actualSize = null;
try
{
using var headReq = new HttpRequestMessage(HttpMethod.Head, url);
using var headResp = await _http.SendAsync(headReq, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
if (headResp.IsSuccessStatusCode)
actualSize = headResp.Content.Headers.ContentLength;
}
catch (Exception ex) { _logger.LogDebug(ex, "HEAD probe after 416 failed"); }
var actualStr = actualSize.HasValue ? $"{actualSize.Value:N0}" : "?";
var expectedStr = $"{state.TotalBytes:N0}";
_logger.LogError(
"HTTP 416 on segment {Seg} — actual server file size: {Actual} bytes, manifest expects: {Expected} bytes",
seg.Index, actualStr, expectedStr);
throw new HttpResumableException(
$"HTTP 416 on segment {seg.Index} — incohérence taille de fichier serveur : " +
$"le ZIP sur le serveur fait {actualStr} octets, le manifest attend {expectedStr} octets. " +
$"Probable cause : upload SFTP tronqué OU manifest avec sizeBytes/sha256 obsolètes. " +
$"Re-upload le ZIP complet puis « 🔁 Hasher les versions + signer » côté admin.",
isTransient: false);
}
throw new HttpResumableException(
$"Signed URL expired (HTTP {(int)resp.StatusCode}), refreshed for next try", isTransient: true);
}
if (!resp.IsSuccessStatusCode)
{
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
throw new HttpResumableException($"HTTP {(int)resp.StatusCode} on segment {seg.Index}", isTransient: transient);
}
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
if (resp.Content.Headers.LastModified is { } lm) state.LastModified = lm.ToString("R");
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true);
dst.Seek(segStart, SeekOrigin.Begin);
// CHECKPOINT : durabilité ET anti-double-comptage.
//
// Durabilité : on n'incrémente seg.DownloadedBytes qu'APRÈS un FlushAsync
// réussi. Sans ça, sur pause/cancel, le buffer FileStream (4 MiB) ou le
// cache OS peuvent ne pas avoir atteint le disque, mais seg.DownloadedBytes
// les compte comme écrits → resume saute ces bytes → trou (zéros NTFS du
// pre-alloc sparse) → SHA-256 fail.
//
// Anti-double-comptage : on appelle ÉGALEMENT `onBytes` uniquement au
// checkpoint, avec le delta inFlight. Sans ça, un retry Polly mid-segment
// (très fréquent sur PHP-FPM OVH mutualisé qui coupe les requêtes longues)
// re-télécharge les bytes depuis le dernier checkpoint et les compte une
// 2e fois dans l'aggregate → footer affiche >100% en fin de DL.
// Avec onBytes au checkpoint : les bytes re-téléchargés n'ont jamais été
// reportés à la 1re tentative (l'erreur Polly est levée AVANT le checkpoint
// sur l'attempt failed), donc pas de double count.
//
// Trade-off : UI/footer update tous les 64 MiB par segment au lieu de
// chaque 4 MiB. Avec 16 segments en parallèle, ça reste ~10-20 updates/s
// au pic, le reporter task échantillonne à 4 Hz donc invisible côté UX.
const long FlushIntervalBytes = 64L * 1024 * 1024;
var buffer = new byte[BufferSize];
long inFlight = 0;
int n;
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
{
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
inFlight += n;
if (inFlight >= FlushIntervalBytes)
{
await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
seg.DownloadedBytes += inFlight;
onBytes(seg.Index, inFlight);
inFlight = 0;
}
}
// Checkpoint final : flush + ack du reste du buffer.
await dst.FlushAsync(CancellationToken.None).ConfigureAwait(false);
if (inFlight > 0)
{
seg.DownloadedBytes += inFlight;
onBytes(seg.Index, inFlight);
inFlight = 0;
}
seg.Completed = (seg.DownloadedBytes >= seg.Length);
// Si on est sorti de la boucle sans avoir atteint la fin du segment, le serveur a
// fermé la connexion sans erreur HTTP (typique : PHP-FPM hit `request_terminate_timeout`
// sur un gros segment, OVH coupe la connexion silencieusement, proxy intermédiaire qui
// ferme). Sans cette détection, le segment retourne `Completed=false` mais sans exception,
// Polly ne retry pas, le fichier final a des trous (zones sparse jamais écrites = zéros
// NTFS) et le SHA-256 échoue à la fin. On lance une exception transitoire pour forcer
// un retry — celui-ci reprendra à l'offset où on s'est arrêté grâce à seg.DownloadedBytes.
if (!seg.Completed)
{
var missing = seg.Length - seg.DownloadedBytes;
throw new HttpResumableException(
$"Segment {seg.Index} : connexion fermée prématurément, {missing:N0} octets manquants (probablement timeout PHP-FPM serveur)",
isTransient: true);
}
}
// ===================================================================
// ============= SINGLE-SEGMENT (legacy / fallback path) ============
// ===================================================================
private async Task<string> DownloadSingleSegmentAsync(
DownloadJob job,
string partialPath,
string finalPath,
DownloadState? existing,
IProgress<DownloadProgress>? progress,
IProgress<double>? hashProgress,
CancellationToken ct)
{
long resumeFrom = 0;
if (existing is not null && File.Exists(partialPath))
{
resumeFrom = new FileInfo(partialPath).Length;
// Si la state.json est en avance par rapport au .partial réel, on s'aligne sur le .partial
if (resumeFrom != existing.DownloadedBytes)
{
_logger.LogInformation(
@@ -92,21 +660,10 @@ public sealed class DownloadManager : IDownloadManager
}
else if (File.Exists(partialPath))
{
// Fichier partiel sans state : on repart à 0 par sécurité
File.Delete(partialPath);
resumeFrom = 0;
}
// 2. Vérif espace disque (uniquement à l'initial, pas au resume)
if (resumeFrom == 0 && job.ExpectedSize > 0)
{
var drive = new DriveInfo(Path.GetPathRoot(_stateStore.GetDownloadsDirectory())!);
var needed = (long)(job.ExpectedSize * 1.05);
if (drive.AvailableFreeSpace < needed)
throw new IOException(
$"Espace disque insuffisant sur {drive.Name} : besoin de {needed:N0} octets, disponible {drive.AvailableFreeSpace:N0}");
}
var state = existing ?? new DownloadState
{
Version = job.Version,
@@ -119,13 +676,11 @@ public sealed class DownloadManager : IDownloadManager
LastChunkAtUtc = DateTime.UtcNow,
};
// 3. Tentatives avec retry — chaque tentative continue depuis la taille actuelle du .partial
await _retryPipeline.ExecuteAsync(async innerCt =>
{
await DownloadChunkAsync(job, state, partialPath, progress, innerCt).ConfigureAwait(false);
}, ct).ConfigureAwait(false);
// 4. Vérif taille finale
var actualSize = new FileInfo(partialPath).Length;
if (job.ExpectedSize > 0 && actualSize != job.ExpectedSize)
{
@@ -134,27 +689,7 @@ public sealed class DownloadManager : IDownloadManager
$"Taille téléchargée incorrecte : attendu {job.ExpectedSize}, obtenu {actualSize}");
}
// 5. Vérif SHA-256
if (!string.IsNullOrEmpty(job.ExpectedSha256) &&
!job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath);
var sha = await _integrity.ComputeSha256Async(partialPath, null, ct).ConfigureAwait(false);
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
{
_stateStore.Discard(job.Version);
throw new InvalidDataException(
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
}
}
else
{
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée");
}
if (File.Exists(finalPath)) File.Delete(finalPath);
File.Move(partialPath, finalPath);
_stateStore.Discard(job.Version); // efface aussi le state.json puisqu'on a fini
await VerifyAndFinalizeAsync(job, partialPath, finalPath, hashProgress, ct).ConfigureAwait(false);
return finalPath;
}
@@ -166,39 +701,31 @@ public sealed class DownloadManager : IDownloadManager
CancellationToken ct)
{
var resumeFrom = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0;
if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return; // déjà tout là
if (state.TotalBytes > 0 && resumeFrom >= state.TotalBytes) return;
using var req = new HttpRequestMessage(HttpMethod.Get, job.Url);
if (resumeFrom > 0)
{
req.Headers.Range = new RangeHeaderValue(resumeFrom, null);
// If-Range : si l'ETag du serveur a changé entre temps, on veut recommencer proprement
if (!string.IsNullOrEmpty(state.Etag))
{
req.Headers.IfRange = new RangeConditionHeaderValue(new EntityTagHeaderValue(state.Etag));
}
else if (!string.IsNullOrEmpty(state.LastModified) &&
DateTimeOffset.TryParse(state.LastModified, out var lm))
{
req.Headers.IfRange = new RangeConditionHeaderValue(lm);
}
}
_logger.LogInformation("HTTP GET {Url} range={Resume}-", job.Url, resumeFrom);
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
if (resumeFrom > 0 && resp.StatusCode == HttpStatusCode.OK)
{
// Le serveur a renvoyé 200 alors qu'on demandait du Range : la ressource a changé.
_logger.LogWarning("Server returned 200 for ranged request — resource changed, restarting fresh");
resp.Dispose();
File.Delete(partialPath);
_stateStore.Discard(job.Version);
// On retry sera traité par Polly via une exception
throw new HttpResumableException("Resource changed on server, restart", isTransient: true);
}
// 5xx / 408 / 429 → exception transitoire pour Polly
if (!resp.IsSuccessStatusCode)
{
var transient = (int)resp.StatusCode is 408 or 429 or >= 500;
@@ -207,11 +734,9 @@ public sealed class DownloadManager : IDownloadManager
isTransient: transient);
}
// Capture ETag et Last-Modified du serveur si présents (pour les futures reprises)
if (resp.Headers.ETag is { } etag) state.Etag = etag.Tag;
if (resp.Content.Headers.LastModified is { } lm2) state.LastModified = lm2.ToString("R");
// Total : si le serveur ne fournit pas Content-Length, on garde la valeur du manifest
var contentLength = resp.Content.Headers.ContentLength ?? 0;
var total = state.TotalBytes;
if (resp.StatusCode == HttpStatusCode.PartialContent && resp.Content.Headers.ContentRange is { Length: { } cl })
@@ -220,8 +745,6 @@ public sealed class DownloadManager : IDownloadManager
total = contentLength;
if (total > 0) state.TotalBytes = total;
// Si on est sur un 200 alors qu'on partait de 0, on écrit en append (FileMode.Append depuis position 0)
// Si on est sur un 206 avec resumeFrom>0, on append.
var mode = resumeFrom > 0 ? FileMode.Append : FileMode.Create;
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
await using var dst = new FileStream(partialPath, mode, FileAccess.Write, FileShare.None, BufferSize, useAsync: true);
@@ -244,7 +767,6 @@ public sealed class DownloadManager : IDownloadManager
var elapsed = sw.Elapsed;
// Progression UI
if ((elapsed - lastReport).TotalMilliseconds >= 250)
{
var deltaBytes = read - lastReportBytes;
@@ -257,7 +779,6 @@ public sealed class DownloadManager : IDownloadManager
lastReportBytes = read;
}
// Persist state.json toutes les 5s ou tous les 100 Mo
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
read - lastFlushBytes >= StateFlushBytesInterval)
{
@@ -272,6 +793,59 @@ public sealed class DownloadManager : IDownloadManager
_stateStore.Save(state);
progress?.Report(new DownloadProgress(read, state.TotalBytes, 0, TimeSpan.Zero));
}
// ===================================================================
// ====================== FINALISATION COMMUNE ======================
// ===================================================================
private async Task VerifyAndFinalizeAsync(
DownloadJob job,
string partialPath,
string finalPath,
IProgress<double>? hashProgress,
CancellationToken ct)
{
// Évalue si on doit vérifier le hash :
// 1. Le manifest doit fournir un sha256 valide (pas vide, pas "REPLACE…")
// 2. L'algo demandé n'est pas "none"
// 3. La config locale n'a pas désactivé la vérification globale
bool hashAlgoSkipped = string.Equals(job.HashAlgorithm, "none", StringComparison.OrdinalIgnoreCase);
bool hasUsableSha = !string.IsNullOrEmpty(job.ExpectedSha256)
&& !job.ExpectedSha256.StartsWith("REPLACE", StringComparison.OrdinalIgnoreCase);
if (hasUsableSha && !hashAlgoSkipped && _config.VerifyDownloadHash)
{
_logger.LogInformation("Verifying SHA-256 of {Path}…", partialPath);
var sw = Stopwatch.StartNew();
var sha = await _integrity.ComputeSha256Async(partialPath, hashProgress, ct).ConfigureAwait(false);
sw.Stop();
_logger.LogInformation("SHA-256 verified in {Elapsed} ({MBps:0.#} MB/s)",
sw.Elapsed,
sw.Elapsed.TotalSeconds > 0 ? new FileInfo(partialPath).Length / sw.Elapsed.TotalSeconds / (1024.0 * 1024.0) : 0);
if (!string.Equals(sha, job.ExpectedSha256, StringComparison.OrdinalIgnoreCase))
{
_stateStore.Discard(job.Version);
throw new InvalidDataException(
$"Checksum SHA-256 invalide : attendu {job.ExpectedSha256}, calculé {sha}");
}
}
else if (hashAlgoSkipped)
{
_logger.LogInformation("Hash verification skipped (manifest specifies hashAlgorithm=none)");
}
else if (!_config.VerifyDownloadHash)
{
_logger.LogInformation("Hash verification skipped (VerifyDownloadHash=false in local config)");
}
else
{
_logger.LogWarning("SHA-256 absent du manifest, vérification ignorée");
}
if (File.Exists(finalPath)) File.Delete(finalPath);
File.Move(partialPath, finalPath);
_stateStore.Discard(job.Version);
}
}
/// <summary>Exception interne pour signaler à Polly qu'on doit retry.</summary>
@@ -283,3 +857,42 @@ internal sealed class HttpResumableException : Exception
IsTransient = isTransient;
}
}
internal static class SparseFileSupport
{
private const uint FSCTL_SET_SPARSE = 0x000900C4;
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DeviceIoControl(
SafeFileHandle hDevice,
uint dwIoControlCode,
IntPtr lpInBuffer,
uint nInBufferSize,
IntPtr lpOutBuffer,
uint nOutBufferSize,
out uint lpBytesReturned,
IntPtr lpOverlapped);
/// <summary>
/// Marque le fichier comme sparse via FSCTL_SET_SPARSE.
/// Une fois sparse, <c>SetLength(14 GB)</c> est métadata-only (&lt; 100 ms partout).
/// Les zones non écrites sont lues comme zéros virtuels (pas de fuite de données
/// disque, contrairement à SetFileValidData qui exigerait SeManageVolumePrivilege).
/// Retourne false si le volume ne supporte pas sparse (ex. FAT32) — auquel cas
/// on retombe sur le SetLength classique avec zero-fill.
/// </summary>
public static bool TrySetSparse(SafeFileHandle handle)
{
if (!OperatingSystem.IsWindows()) return false;
try
{
return DeviceIoControl(handle, FSCTL_SET_SPARSE,
IntPtr.Zero, 0, IntPtr.Zero, 0, out _, IntPtr.Zero);
}
catch
{
return false;
}
}
}

View File

@@ -9,6 +9,17 @@ public interface IDownloadManager
IProgress<DownloadProgress>? progress,
CancellationToken ct);
/// <summary>
/// Variante avec callback de progression dédié pour la phase de vérification SHA-256.
/// La phase verify est CPU/IO-bound et peut prendre plusieurs minutes sur un build 14 Go ;
/// la UI doit pouvoir l'afficher distinctement du DL.
/// </summary>
Task<string> DownloadAsync(
DownloadJob job,
IProgress<DownloadProgress>? progress,
IProgress<double>? hashProgress,
CancellationToken ct);
/// <summary>Retourne l'état de DL persistant pour cette version, ou null s'il n'y en a pas.</summary>
DownloadState? GetResumableState(string version);
@@ -20,4 +31,20 @@ public sealed record DownloadJob(
string Version,
Uri Url,
long ExpectedSize,
string ExpectedSha256);
string ExpectedSha256)
{
/// <summary>
/// Algo de vérif d'intégrité — "sha256" (défaut) ou "none" pour sauter la vérif.
/// Pris depuis <see cref="PSLauncher.Models.DownloadDescriptor.HashAlgorithm"/>.
/// </summary>
public string? HashAlgorithm { get; init; }
/// <summary>
/// Callback pour obtenir une URL HMAC-signée fraîche en cas d'expiration mid-DL
/// (le DL d'un 14 Go sur connexion lente peut dépasser le TTL serveur de 6 h).
/// Le DownloadManager appelle ce callback quand un segment reçoit un 403/410
/// (indices d'URL expirée), récupère une URL fraîche, et reprend transparemment
/// le DL sans l'interrompre. Si null, pas d'auto-refresh : le DL fail proprement.
/// </summary>
public Func<CancellationToken, Task<Uri?>>? RefreshUrlAsync { get; init; }
}

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

@@ -8,4 +8,29 @@ public interface IInstallationRegistry
InstalledVersion? Get(string version);
bool IsInstalled(string version);
Task DeleteAsync(string version, IProgress<double>? progress, CancellationToken ct);
/// <summary>
/// Écrit le metadata <c>.proserve-meta.json</c> dans le dossier d'install avec
/// le nom du .exe à lancer (récupéré du manifest serveur) + l'identifiant
/// d'entrée manifest (<paramref name="entryId"/>) pour détecter les
/// collisions inter-channels. À appeler par <c>MainViewModel</c> après
/// l'extraction du ZIP, pour que le futur <c>Scan</c> trouve le bon exe et
/// pour que l'install guard puisse comparer l'entryId d'un dossier existant
/// avant écrasement.
/// </summary>
Task WriteInstallMetadataAsync(string installDir, string executableName, string? entryId, CancellationToken ct);
/// <summary>Marque les redists comme installés (timestamp UTC) dans la metadata.</summary>
Task MarkRedistInstalledAsync(string installDir, CancellationToken ct);
/// <summary>Vrai si la metadata indique que les redists ont déjà été installés.</summary>
bool IsRedistInstalled(string installDir);
/// <summary>
/// Lit l'<c>entryId</c> stocké dans <c>.proserve-meta.json</c> du dossier
/// d'install. Retourne null si le fichier n'existe pas, si la clé n'a
/// jamais été écrite (installs antérieurs à ce feature), ou en cas
/// d'erreur de parse. Utilisé pour l'install guard anti-collision.
/// </summary>
string? TryReadEntryId(string installDir);
}

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
@@ -6,9 +7,35 @@ namespace PSLauncher.Core.Installations;
public sealed partial class InstallationRegistry : IInstallationRegistry
{
private const string ExecutableName = "PROSERVE_UE_5_5.exe";
/// <summary>
/// Fichier metadata écrit à l'install par MainViewModel. Contient le nom de l'exe
/// déclaré dans le manifest pour cette version. Permet à <see cref="Scan"/> de
/// retrouver le bon .exe à lancer sans avoir le manifest en mémoire (cas du
/// démarrage offline ou avant le 1er Check Updates).
/// </summary>
public const string MetadataFileName = ".proserve-meta.json";
[GeneratedRegex(@"^Proserve v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
/// <summary>
/// Pattern de fallback quand .proserve-meta.json est absent (vieux installs
/// d'avant l'introduction du metadata). Couvre toutes les versions Unreal
/// connues (UE_5_5, UE_5_7, etc.). Premier match alphabétique gagne.
/// </summary>
private const string FallbackExePattern = "PROSERVE_UE_*.exe";
// IgnoreCase pour matcher à la fois les anciens dossiers "Proserve vX.Y.Z" et les
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque). Le 4ᵉ digit
// optionnel (?:\.\d+)? permet aux itérations dev/test (1.5.4.13) de
// cohabiter avec leur release stable (1.5.4) dans deux dossiers distincts.
//
// Le groupe optionnel `(-<channel>)?` permet de discriminer plusieurs installs
// au même numéro de version mais sur des channels différents :
// PROSERVE v1.5.4.32
// PROSERVE-firefighter v1.5.4.32
// PROSERVE-full v1.5.4.32
// Le channel n'est PAS capturé ici — c'est juste un suffixe autorisé dans le
// nom de dossier. L'association exacte channel ↔ dossier passe par le lookup
// <see cref="VersionManifest.GetInstallFolderName"/> dans MainViewModel.
[GeneratedRegex(@"^PROSERVE(-[a-z0-9_-]+)? v(?<v>\d+\.\d+\.\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase)]
private static partial Regex VersionFolderRegex();
private readonly ILogger<InstallationRegistry> _logger;
@@ -36,10 +63,10 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
var match = VersionFolderRegex().Match(name);
if (!match.Success) continue;
var exe = Path.Combine(dir, ExecutableName);
if (!File.Exists(exe))
var exe = ResolveExecutable(dir);
if (exe is null)
{
_logger.LogDebug("Skipped {Dir}: missing {Exe}", dir, ExecutableName);
_logger.LogDebug("Skipped {Dir}: no executable found (no .proserve-meta.json + no fallback match)", dir);
continue;
}
@@ -49,7 +76,8 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
FolderPath: dir,
ExecutablePath: exe,
InstalledAt: info.CreationTimeUtc,
SizeBytes: SafeDirectorySize(dir)));
SizeBytes: SafeDirectorySize(dir),
EntryId: TryReadEntryId(dir)));
}
return results
@@ -57,6 +85,145 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
.ToList();
}
/// <summary>
/// Résout le .exe à lancer pour un dossier d'install donné. Stratégie :
/// 1. Lit <c>.proserve-meta.json</c> (écrit par MainViewModel à l'install,
/// contient l'exe déclaré dans le manifest serveur — source canonique).
/// 2. Fallback glob <c>PROSERVE_UE_*.exe</c> pour les vieux installs sans meta.
/// 3. Null si aucun match (dossier corrompu ou pas un install PROSERVE).
/// </summary>
private string? ResolveExecutable(string installDir)
{
// Tentative 1 : metadata écrite par le launcher au moment de l'install
var metaPath = Path.Combine(installDir, MetadataFileName);
if (File.Exists(metaPath))
{
try
{
var raw = File.ReadAllText(metaPath);
var meta = JsonSerializer.Deserialize<InstallMetadata>(raw);
if (meta is { Executable: var exeName } && !string.IsNullOrWhiteSpace(exeName))
{
var candidate = Path.Combine(installDir, exeName);
if (File.Exists(candidate)) return candidate;
_logger.LogWarning("Metadata declares {Exe} but file missing in {Dir} — falling back to glob", exeName, installDir);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to parse {Meta} (will fall back to glob)", metaPath);
}
}
// Tentative 2 : glob pattern (couvre vieux installs sans metadata)
try
{
var matches = Directory.GetFiles(installDir, FallbackExePattern, SearchOption.TopDirectoryOnly);
if (matches.Length > 0)
{
Array.Sort(matches, StringComparer.OrdinalIgnoreCase);
return matches[0];
}
}
catch { /* ignore IO error */ }
return null;
}
/// <summary>
/// Écrit le fichier metadata <c>.proserve-meta.json</c> dans le dossier d'install
/// fraîchement extrait. Appelé par <see cref="MainViewModel"/> après le ZipInstaller,
/// avec l'exe déclaré dans le manifest (<c>VersionManifest.Executable</c>) et
/// l'<paramref name="entryId"/> de l'entrée manifest source. L'entryId sert au
/// pre-install guard : un futur install qui vise le même dossier avec un id
/// différent (= une autre entrée manifest sur un channel différent partageant
/// le numéro de version) sera refusé pour éviter l'écrasement silencieux.
/// </summary>
public async Task WriteInstallMetadataAsync(string installDir, string executableName, string? entryId, CancellationToken ct)
{
var path = Path.Combine(installDir, MetadataFileName);
// Préserve un éventuel RedistInstalledAt existant (relance install sans
// ré-installer les redists → on ne veut pas perdre le timestamp).
InstallMetadata? existing = null;
if (File.Exists(path))
{
try { existing = JsonSerializer.Deserialize<InstallMetadata>(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); }
catch { existing = null; }
}
var meta = new InstallMetadata
{
Executable = executableName,
EntryId = entryId,
RedistInstalledAt = existing?.RedistInstalledAt,
};
var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public string? TryReadEntryId(string installDir)
{
var path = Path.Combine(installDir, MetadataFileName);
if (!File.Exists(path)) return null;
try
{
var meta = JsonSerializer.Deserialize<InstallMetadata>(File.ReadAllText(path));
return string.IsNullOrWhiteSpace(meta?.EntryId) ? null : meta.EntryId;
}
catch { return null; }
}
/// <summary>
/// Marque dans la metadata qu'on a installé avec succès les redists Unreal
/// (contenu de <c>_redist/</c> de la version). Évite de re-runner les UAC
/// popups si l'utilisateur réinstalle la même version.
/// </summary>
public async Task MarkRedistInstalledAsync(string installDir, CancellationToken ct)
{
var path = Path.Combine(installDir, MetadataFileName);
InstallMetadata? meta = null;
if (File.Exists(path))
{
try { meta = JsonSerializer.Deserialize<InstallMetadata>(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); }
catch { meta = null; }
}
meta ??= new InstallMetadata();
meta.RedistInstalledAt = DateTime.UtcNow;
var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false);
}
/// <summary>
/// Vrai si l'install dir a déjà eu son <c>_redist/</c> installé une fois (via
/// metadata). Permet de skip le UAC popup chain à chaque réinstall de la même
/// version.
/// </summary>
public bool IsRedistInstalled(string installDir)
{
var path = Path.Combine(installDir, MetadataFileName);
if (!File.Exists(path)) return false;
try
{
var meta = JsonSerializer.Deserialize<InstallMetadata>(File.ReadAllText(path));
return meta?.RedistInstalledAt is not null;
}
catch { return false; }
}
private sealed class InstallMetadata
{
public string Executable { get; set; } = string.Empty;
public DateTime? RedistInstalledAt { get; set; }
/// <summary>
/// Id de l'entrée manifest source (<see cref="VersionManifest.Id"/>). Null
/// pour les installs antérieurs à cette feature (rétro-compat). Utilisé
/// par l'install guard : refuse un install si un autre entryId est déjà
/// présent dans le dossier cible.
/// </summary>
public string? EntryId { get; set; }
}
public InstalledVersion? Get(string version) =>
Scan().FirstOrDefault(v => v.Version == version);

View File

@@ -1,18 +1,52 @@
namespace PSLauncher.Core.Installations;
public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable<SemVer>
/// <summary>
/// Version produit PROSERVE. Formats acceptés :
/// <list type="bullet">
/// <item><c>X.Y.Z</c> (3 digits — releases publiques)</item>
/// <item><c>X.Y.Z.B</c> (4 digits — itérations dev/test internes)</item>
/// </list>
/// Le 4ᵉ digit <see cref="Build"/> est sémantiquement "post-release" :
/// <c>1.5.4 == 1.5.4.0 &lt; 1.5.4.1 &lt; 1.5.4.13 &lt; 1.5.5</c>.
/// L'absence de 4ᵉ digit est équivalente à <c>.0</c> pour la comparaison,
/// mais conservée distincte dans <see cref="ToString"/> (= <c>1.5.4</c>,
/// PAS <c>1.5.4.0</c>) pour ne pas re-écrire les versions historiques
/// du manifest qui n'avaient que 3 digits.
/// </summary>
public readonly record struct SemVer(int Major, int Minor, int Patch, int Build = 0) : IComparable<SemVer>
{
/// <summary>
/// True si la version originale avait un 4ᵉ digit explicite (même <c>.0</c>).
/// Sert uniquement à <see cref="ToString"/> pour préserver le format d'origine
/// lors d'un round-trip Parse → ToString.
/// </summary>
public bool HasExplicitBuild { get; init; }
public static SemVer Parse(string s)
{
if (string.IsNullOrWhiteSpace(s)) return new SemVer(0, 0, 0);
var parts = s.Split('.');
if (parts.Length != 3
|| !int.TryParse(parts[0], out var maj)
// 3 digits = release publique (X.Y.Z), 4 digits = itération dev (X.Y.Z.B).
// Tout autre format = parse-fail → on retourne 0.0.0 (silencieux, comme
// l'ancien comportement, pour ne pas casser sur un manifest exotique).
if (parts.Length < 3 || parts.Length > 4) return new SemVer(0, 0, 0);
if (!int.TryParse(parts[0], out var maj)
|| !int.TryParse(parts[1], out var min)
|| !int.TryParse(parts[2], out var pat))
{
return new SemVer(0, 0, 0);
}
return new SemVer(maj, min, pat);
if (parts.Length == 3)
{
return new SemVer(maj, min, pat, 0) { HasExplicitBuild = false };
}
// 4 digits — itération dev
if (!int.TryParse(parts[3], out var build))
{
return new SemVer(0, 0, 0);
}
return new SemVer(maj, min, pat, build) { HasExplicitBuild = true };
}
public int CompareTo(SemVer other)
@@ -21,8 +55,13 @@ public readonly record struct SemVer(int Major, int Minor, int Patch) : ICompara
if (c != 0) return c;
c = Minor.CompareTo(other.Minor);
if (c != 0) return c;
return Patch.CompareTo(other.Patch);
c = Patch.CompareTo(other.Patch);
if (c != 0) return c;
// Build absent = 0 implicite → 1.5.4 == 1.5.4.0 < 1.5.4.1
return Build.CompareTo(other.Build);
}
public override string ToString() => $"{Major}.{Minor}.{Patch}";
public override string ToString() => HasExplicitBuild || Build > 0
? $"{Major}.{Minor}.{Patch}.{Build}"
: $"{Major}.{Minor}.{Patch}";
}

View File

@@ -0,0 +1,55 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Installations;
/// <summary>
/// Comparaison de versions PROSERVE qui prend en compte le flag <c>isBeta</c>
/// du manifest en plus du SemVer numérique.
///
/// Règle : au même préfixe 3-digit <c>X.Y.Z</c>, une version <c>isBeta=false</c>
/// est considérée SUPÉRIEURE à une version <c>isBeta=true</c>, quel que soit le
/// 4ᵉ digit. Cas d'usage typique : après avoir itéré des builds beta <c>1.5.4.30
/// .31 .32</c>, l'opérateur publie <c>1.5.4</c> non-beta comme release finale.
/// Sous SemVer pur, <c>1.5.4 == 1.5.4.0 &lt; 1.5.4.32</c> donc le launcher
/// resterait pointé sur la beta ; ce helper inverse la comparaison pour ce
/// cas précis afin que la release finale soit considérée comme LA version
/// courante.
///
/// Hors ce cas (préfixes 3-digit différents, ou même statut beta des deux
/// côtés), on retombe sur <see cref="SemVer.CompareTo"/> — donc :
/// <list type="bullet">
/// <item><c>1.5.4 (non-beta)</c> vs <c>1.5.4.32 (beta)</c> → 1.5.4 gagne (règle spéciale)</item>
/// <item><c>1.5.4.30 (beta)</c> vs <c>1.5.4.32 (beta)</c> → 1.5.4.32 gagne (SemVer)</item>
/// <item><c>1.5.4 (non-beta)</c> vs <c>1.5.5.10 (beta)</c> → 1.5.5.10 gagne (SemVer, préfixes différents)</item>
/// <item><c>1.5.4</c> vs <c>1.5.5</c> → 1.5.5 gagne (SemVer)</item>
/// </list>
/// </summary>
public static class VersionOrder
{
/// <summary>
/// Retourne &lt;0 si A &lt; B, 0 si égales, &gt;0 si A &gt; B. Voir la doc de
/// <see cref="VersionOrder"/> pour la règle isBeta.
/// </summary>
public static int Compare(string versionA, bool isBetaA, string versionB, bool isBetaB)
{
var svA = SemVer.Parse(versionA);
var svB = SemVer.Parse(versionB);
// Cas spécial : même préfixe 3-digit ET statut beta différent.
// La version non-beta l'emporte, indépendamment du 4ᵉ digit (traité
// comme un suffixe pré-release dans ce contexte particulier).
if (svA.Major == svB.Major
&& svA.Minor == svB.Minor
&& svA.Patch == svB.Patch
&& isBetaA != isBetaB)
{
return isBetaA ? -1 : 1;
}
return svA.CompareTo(svB);
}
/// <summary>Overload pratique pour une <see cref="VersionManifest"/>.</summary>
public static int Compare(VersionManifest a, VersionManifest b)
=> Compare(a.Version, a.IsBeta, b.Version, b.IsBeta);
}

View File

@@ -26,11 +26,70 @@ public sealed class ZipInstaller : IZipInstaller
{
await Task.Run(() => ExtractZip(zipPath, stagingFolder, progress, ct), ct).ConfigureAwait(false);
// Si la version est DÉJÀ installée (cas typique : ré-installation
// d'un build sans bumper le numéro de version pendant des tests),
// on renomme l'existant en backup horodaté pour ne pas bloquer
// l'install. Le backup est supprimé en arrière-plan après que le
// swap soit confirmé (sinon on doublerait la conso disque le temps
// de la copie). Si le swap échoue, on restaure le backup.
string? renamedBackup = null;
if (Directory.Exists(targetFolder))
throw new InvalidOperationException($"Le dossier cible existe déjà : {targetFolder}");
{
renamedBackup = $"{targetFolder}.bak-{DateTime.UtcNow:yyyyMMdd-HHmmss}";
Directory.Move(targetFolder, renamedBackup);
_logger.LogInformation(
"Existing install at {Target} renamed to {Backup} before re-install",
targetFolder, renamedBackup);
}
try
{
Directory.Move(stagingFolder, targetFolder);
}
catch
{
// Rollback : restaure le backup si on a échoué après le rename
if (renamedBackup is not null && Directory.Exists(renamedBackup) && !Directory.Exists(targetFolder))
{
try
{
Directory.Move(renamedBackup, targetFolder);
_logger.LogWarning("Restored previous install from {Backup} after move failure", renamedBackup);
}
catch (Exception restoreEx)
{
_logger.LogError(restoreEx,
"FAILED to restore backup {Backup} — manual recovery needed", renamedBackup);
}
}
throw;
}
_logger.LogInformation("Installed to {Target}", targetFolder);
// Cleanup background du backup (14 Go d'install × N réinstallations
// remplirait le disque sinon). Best-effort : si on ne peut pas
// delete (handle Windows tenace, AV qui scanne), on log et on
// laisse à l'opérateur (le nom .bak-{ts} le rend repérable).
if (renamedBackup is not null)
{
var bakToDelete = renamedBackup;
_ = Task.Run(() =>
{
try
{
Directory.Delete(bakToDelete, recursive: true);
_logger.LogInformation("Cleaned up old install backup {Backup}", bakToDelete);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Could not delete old install backup {Backup} — operator can remove manually",
bakToDelete);
}
});
}
return targetFolder;
}
catch

View File

@@ -1,26 +1,63 @@
using System.Buffers;
using System.Security.Cryptography;
namespace PSLauncher.Core.Integrity;
/// <summary>
/// Calcul SHA-256 streaming optimisé pour gros fichiers (14+ Go) :
/// - Buffer 16 MiB (vs 1 MiB historique) pour saturer le throughput SSD/HDD ;
/// - <see cref="FileOptions.SequentialScan"/> hint pour que Windows fasse du read-ahead agressif ;
/// - <see cref="FileOptions.Asynchronous"/> pour ne pas bloquer le ThreadPool en I/O sync ;
/// - <see cref="IncrementalHash"/> (au lieu de TransformBlock/TransformFinalBlock) qui est plus moderne,
/// moins GC-heavy, et bénéficie sur .NET 8 des intrinsics SHA hardware (SHA-NI Intel/AMD,
/// ARMv8 Crypto Extensions) quand la CPU les supporte → typiquement 2-5× plus rapide.
/// - Double-buffering : on lit le buffer N+1 pendant qu'on hash le buffer N, ce qui permet de
/// recouvrir CPU (hash) et I/O (read) sur les machines où ils ne sont pas le même goulot.
///
/// Sur un build 14 Go, l'impact typique constaté :
/// ~1 MiB buffer + TransformBlock : 60-180 s
/// ~16 MiB buffer + IncrementalHash + double-buffer : 15-40 s
/// </summary>
public sealed class IntegrityService : IIntegrityService
{
private const int BufferSize = 16 << 20; // 16 MiB
public async Task<string> ComputeSha256Async(string filePath, IProgress<double>? progress, CancellationToken ct)
{
const int bufferSize = 1 << 20; // 1 MiB
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize, useAsync: true);
using var sha = SHA256.Create();
var fileOptions = FileOptions.Asynchronous | FileOptions.SequentialScan;
await using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, fileOptions);
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
var buffer = new byte[bufferSize];
// Pool pour ne pas allouer 32 Mo à chaque appel.
var pool = ArrayPool<byte>.Shared;
byte[] bufA = pool.Rent(BufferSize);
byte[] bufB = pool.Rent(BufferSize);
try
{
long total = fs.Length;
long read = 0;
int n;
while ((n = await fs.ReadAsync(buffer.AsMemory(0, bufferSize), ct).ConfigureAwait(false)) > 0)
// Premier read amorce le pipeline.
int n = await fs.ReadAsync(bufA.AsMemory(0, BufferSize), ct).ConfigureAwait(false);
while (n > 0)
{
sha.TransformBlock(buffer, 0, n, null, 0);
// Lance le prochain read en parallèle pendant qu'on hash le buffer courant.
var nextReadTask = fs.ReadAsync(bufB.AsMemory(0, BufferSize), ct);
hasher.AppendData(bufA, 0, n);
read += n;
if (total > 0) progress?.Report((double)read / total);
int next = await nextReadTask.ConfigureAwait(false);
// Swap : bufB devient le buffer courant, bufA le futur prochain read.
(bufA, bufB) = (bufB, bufA);
n = next;
}
return Convert.ToHexString(hasher.GetHashAndReset()).ToLowerInvariant();
}
finally
{
pool.Return(bufA);
pool.Return(bufB);
}
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
return Convert.ToHexString(sha.Hash!).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,24 @@
namespace PSLauncher.Core.Lan;
/// <summary>
/// Serveur HTTP local qui expose les ZIPs cachés aux peers du LAN.
///
/// Routes :
/// - <c>GET /v1/has/{version}</c> → JSON <c>{sha256, size, mtime}</c> ou 404
/// - <c>GET /v1/zip/{version}</c> → stream du ZIP avec support Range
///
/// Sécurité :
/// - Bind sur les IPs locales LAN uniquement (pas <c>+</c> ni <c>*</c>) → pas
/// besoin d'URL ACL admin pour démarrer.
/// - Filtre <c>RemoteEndPoint</c> : refuse 403 toute requête venant d'une IP
/// non-RFC1918 / non-link-local.
/// - Validation regex stricte sur <c>{version}</c> avant tout File.IO.
/// - SemaphoreSlim(4) pour limiter à 4 DLs simultanés et éviter de DoS le PC
/// serveur sous charge.
/// </summary>
public interface ILanCacheServer
{
bool IsRunning { get; }
int? BoundPort { get; }
IReadOnlyList<string> BoundEndpoints { get; }
}

View File

@@ -0,0 +1,37 @@
namespace PSLauncher.Core.Lan;
/// <summary>
/// Un peer découvert via le broadcast UDP. <see cref="LastSeenUtc"/> sert au TTL :
/// si le peer ne renvoie plus de beacon pendant > 60 s, il est considéré offline
/// et n'apparaît plus dans <see cref="ILanDiscoveryService.ListDiscovered"/>.
/// </summary>
public sealed record DiscoveredPeer(
string Host,
int Port,
IReadOnlyList<string> Versions,
DateTime LastSeenUtc);
/// <summary>
/// Auto-découverte des peers PSLauncher du LAN via UDP broadcast.
///
/// Mode serveur (ServerEnabled=true) : émet un beacon JSON sur 255.255.255.255:DiscoveryPort
/// toutes les 15 s avec son host/port HTTP + la liste des versions cachées.
///
/// Mode client (ClientEnabled=true OU ServerEnabled=true) : écoute sur le port,
/// parse les beacons, maintient un dictionnaire interne avec TTL.
///
/// Note : le mode serveur écoute aussi (pour ne pas avoir à demander à l'OS deux
/// fois le port via SO_REUSEADDR), mais il ignore ses propres beacons.
/// </summary>
public interface ILanDiscoveryService
{
/// <summary>Liste des peers vus récemment (lastSeen >= now - 60s). Vide si discovery désactivée.</summary>
IReadOnlyList<DiscoveredPeer> ListDiscovered();
/// <summary>
/// Peers connus de runs précédents, chargés depuis le cache disque au démarrage
/// (max 1 semaine). Utile en cold start pour éviter d'attendre les 15 s du
/// premier beacon UDP avant qu'un peer apparaisse.
/// </summary>
IReadOnlyList<DiscoveredPeer> ListKnown();
}

View File

@@ -0,0 +1,21 @@
namespace PSLauncher.Core.Lan;
/// <summary>
/// Récupère le manifest depuis un peer LAN. Sert quand le client n'a pas
/// d'accès Internet : il interroge les peers découverts via UDP discovery
/// (et les peers manuels en config) pour trouver un PC ayant déjà fetché le
/// manifest depuis OVH récemment.
///
/// La sécurité reste celle du manifest signé Ed25519 — le peer pourrait
/// servir n'importe quel JSON, mais le launcher rejettera tout manifest non
/// signé correctement (ou tout ZIP dont le SHA-256 ne match pas).
/// </summary>
public interface IPeerManifestFetcher
{
/// <summary>
/// Tente de récupérer le manifest brut (JSON, byte-for-byte) depuis le
/// premier peer qui répond. Null si aucun peer atteignable, no-op si
/// ClientEnabled=false.
/// </summary>
Task<string?> TryFetchRawAsync(CancellationToken ct);
}

Some files were not shown because too many files have changed in this diff Show More