Compare commits

..

89 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
354 changed files with 57049 additions and 524 deletions

30
.gitignore vendored
View File

@@ -28,6 +28,17 @@ 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
@@ -41,11 +52,20 @@ server/builds/*
# Plans
.claude/
# Binaires copiés par le post-publish à la racine du repo
/PSLauncher.exe
/PSLauncher.Updater.exe
/PSLauncher-*.exe
/PSLauncher*.bak
# 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 ~$
~$*

View File

@@ -6,8 +6,8 @@ setlocal
set "REPO=%~dp0"
echo.
echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe
taskkill /F /IM PSLauncher.exe /T >nul 2>&1
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
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.
@@ -22,8 +22,8 @@ if %ERRORLEVEL% NEQ 0 goto :fail
echo.
echo [OK] Les deux exes sont disponibles a la racine du repo :
echo %REPO%PSLauncher.exe
echo %REPO%PSLauncher.Updater.exe
echo %REPO%PS_Launcher.exe
echo %REPO%PS_Launcher.Updater.exe
echo.
pause
endlocal & exit /b 0

View File

@@ -13,8 +13,8 @@ setlocal
set "SCRIPT_DIR=%~dp0"
REM Termine les eventuelles instances en cours qui verrouilleraient les .exe
taskkill /F /IM PSLauncher.exe /T >nul 2>&1
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
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%"

View File

@@ -1,13 +1,13 @@
@echo off
REM Build du launcher principal (PSLauncher.exe).
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe (~78 Mo single-file self-contained)
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 PSLauncher qui verrouilleraient les .exe
taskkill /F /IM PSLauncher.exe /T >nul 2>&1
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
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.
@@ -19,9 +19,9 @@ echo.
if %EXITCODE% NEQ 0 (
echo [ECHEC] Build du launcher echoue avec le code %EXITCODE%.
) else (
echo [OK] PSLauncher.exe disponible a la racine du repo :
echo %REPO%PSLauncher.exe
echo [OK] PS_Launcher.exe disponible a la racine du repo :
echo %REPO%PS_Launcher.exe
)
echo.
pause
endlocal & exit /b %EXITCODE%
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

View File

@@ -1,14 +1,14 @@
@echo off
REM Build de l'updater (PSLauncher.Updater.exe).
REM Sortie : C:\ASTERION\GIT\PS_Launcher\PSLauncher.Updater.exe (~34 Mo single-file)
REM L'updater accompagne PSLauncher.exe — il doit etre dans le meme dossier.
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 PSLauncher.exe /T >nul 2>&1
taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1
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.
@@ -20,9 +20,9 @@ echo.
if %EXITCODE% NEQ 0 (
echo [ECHEC] Build de l'updater echoue avec le code %EXITCODE%.
) else (
echo [OK] PSLauncher.Updater.exe disponible a la racine du repo :
echo %REPO%PSLauncher.Updater.exe
echo [OK] PS_Launcher.Updater.exe disponible a la racine du repo :
echo %REPO%PS_Launcher.Updater.exe
)
echo.
pause
endlocal & exit /b %EXITCODE%
endlocal ^& exit /b %EXITCODE%

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.8.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,19 +28,23 @@ AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
; Installation user-mode (pas d'admin requis) : le launcher pourra se mettre à jour
; tout seul via PSLauncher.Updater.exe sans déclencher de UAC.
DefaultDirName={localappdata}\Programs\{#MyAppPublisher}\{#MyAppShortName}
; 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}
OutputBaseFilename=PS_Launcher-Setup-{#MyAppVersion}
SetupIconFile=..\src\PSLauncher.App\Resources\favicon.ico
Compression=lzma2/ultra
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
PrivilegesRequired=admin
UsedUserAreasWarning=no
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
@@ -61,6 +65,14 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription
Source: "..\src\PSLauncher.App\bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\src\PSLauncher.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}"
@@ -69,11 +81,55 @@ 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

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

View File

@@ -42,10 +42,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($action === 'set_launcher') {
$lver = trim($_POST['launcher_version'] ?? '');
$lmin = trim($_POST['launcher_min_required'] ?? '');
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) {
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).');
// 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+$/', $lmin)) {
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';
@@ -53,14 +54,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'version' => $lver,
'minRequired' => $lmin !== '' ? $lmin : '0.5.0',
'download' => [
'url' => "{$base}/builds/launcher/PSLauncher-{$lver}.exe",
'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 PSLauncher-{$lver}.exe dans builds/launcher/ puis clique « Hasher le launcher + signer ».";
$message = "Section launcher mise à jour (v{$lver}). Upload PS_Launcher-{$lver}.exe dans builds/launcher/ puis clique « Hasher le launcher + signer ».";
}
elseif ($action === 'remove_launcher') {
unset($manifest['launcher']);
@@ -76,6 +77,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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';
@@ -130,7 +132,7 @@ Layout::header('Launcher', 'launcher');
<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>PSLauncher.exe → PSLauncher-X.Y.Z.exe</code>.</li>
<li>Renomme la copie : <code>PS_Launcher.exe → PS_Launcher-X.Y.Z.exe</code>.</li>
<li>Upload via SFTP dans <code>www/PS_Launcher/builds/launcher/</code>.</li>
<li>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>
@@ -144,14 +146,14 @@ Layout::header('Launcher', 'launcher');
<input type="hidden" name="action" value="set_launcher">
<div class="row">
<div class="col field">
<label>Version launcher (X.Y.Z)</label>
<label>Version launcher (X.Y.Z ou X.Y.Z.B)</label>
<input type="text" name="launcher_version" placeholder="0.6.0"
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+">
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+(\.\d+)?">
</div>
<div class="col field">
<label>Version minimale requise (X.Y.Z)</label>
<label>Version minimale requise (X.Y.Z ou X.Y.Z.B)</label>
<input type="text" name="launcher_min_required" placeholder="0.5.0"
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+">
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+(\.\d+)?">
</div>
</div>
<button class="btn btn-success" type="submit">Définir</button>

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,7 @@ 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'],
];

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,7 +19,8 @@ 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);
}
@@ -74,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
@@ -95,6 +120,46 @@ 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)
@@ -102,7 +167,7 @@ final class DownloadUrl
// 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';
$relPath = '/builds/' . $filename;
$exp = time() + 21600; // 6 h
$secret = $config['hmac_secret'] ?? '';
if ($secret === '') {

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,
];

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

@@ -56,16 +56,26 @@ final class SignManifest
* 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): array
private function getOrComputeSha256(string $path, array &$cache, bool $force = false): array
{
$size = filesize($path) ?: 0;
$mtime = filemtime($path) ?: 0;
$key = realpath($path) ?: $path;
if (isset($cache[$key])
if (!$force
&& isset($cache[$key])
&& ($cache[$key]['size'] ?? -1) === $size
&& ($cache[$key]['mtime'] ?? -1) === $mtime
&& !empty($cache[$key]['sha256'])) {
@@ -73,7 +83,37 @@ final class SignManifest
}
$start = microtime(true);
$sha = hash_file('sha256', $path);
// 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];
@@ -99,11 +139,57 @@ final class SignManifest
* @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.
* 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(string $scope = 'all', bool $force = false, ?string $onlyVersion = null): 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];
@@ -117,9 +203,9 @@ final class SignManifest
$doVersions = ($scope === 'all' || $scope === 'versions');
$doLauncher = ($scope === 'all' || $scope === 'launcher');
if ($onlyVersion !== null) {
// Mode "hash une seule version" : on ne touche pas au launcher,
// et on ne hash que la version demandée dans la section versions[].
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;
}
@@ -131,7 +217,15 @@ final class SignManifest
$hashedVersions = [];
if ($doVersions) foreach ($manifest['versions'] as &$v) {
$version = $v['version'] ?? '?';
if ($onlyVersion !== null && $version !== $onlyVersion) {
$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'] ?? '';
@@ -192,7 +286,26 @@ final class SignManifest
}
}
$r = $this->getOrComputeSha256($zip, $cache);
// 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");
@@ -231,7 +344,7 @@ final class SignManifest
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
} else {
$r = $this->getOrComputeSha256($lpath, $cache);
$r = $this->getOrComputeSha256($lpath, $cache, $force);
$cacheChanged = true;
$launcher['download']['sha256'] = $r['sha256'];
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";

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

@@ -16,7 +16,16 @@ 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;
@@ -26,6 +35,13 @@ namespace PSLauncher.App;
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";
@@ -64,6 +80,19 @@ public partial class App : Application
"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
@@ -91,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");
@@ -109,16 +163,16 @@ public partial class App : Application
services.AddSingleton(sp =>
{
// Handler tuné pour les gros téléchargements parallèles :
// - MaxConnectionsPerServer=16 pour autoriser le multi-segment (8 par défaut).
// - AutomaticDecompression sur l'API JSON uniquement (None ici car les builds
// sont des ZIPs déjà compressés ; éviter la CPU+RAM gaspillée par un éventuel
// wrapping gzip côté serveur).
// - 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),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(60),
MaxConnectionsPerServer = 16,
MaxConnectionsPerServer = 32,
EnableMultipleHttp2Connections = true,
AutomaticDecompression = System.Net.DecompressionMethods.None,
};
@@ -142,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>();
@@ -158,16 +222,105 @@ 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).
@@ -177,9 +330,67 @@ public partial class App : Application
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,15 +9,18 @@
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Le générateur WinRT (CsWinRT v2.x, déclenché par WebView2/UWP refs sur SDK récent)
émet du code unsafe dans WinRTGenericInstantiation.g.cs. Sans ce flag, build = CS0227. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationIcon>Resources\favicon.ico</ApplicationIcon>
<AssemblyName>PSLauncher</AssemblyName>
<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.9.0</Version>
<AssemblyVersion>0.9.0.0</AssemblyVersion>
<FileVersion>0.9.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>
@@ -40,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>
@@ -55,7 +63,7 @@
</ItemGroup>
<!-- Après chaque `dotnet publish -c Release`, copie le single-file exe à la racine du repo
(C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe). ContinueOnError pour ne pas casser le
(C:\ASTERION\GIT\PS_Launcher\PS_Launcher.exe). ContinueOnError pour ne pas casser le
publish si une instance du launcher tourne et verrouille la cible — l'opérateur peut
killer et réessayer, ou simplement utiliser le binaire depuis bin\Release\... -->
<Target Name="CopyPublishedToRepoRoot" AfterTargets="Publish"

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" />
@@ -239,6 +287,47 @@
</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. -->

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -10,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,
}
@@ -25,6 +26,73 @@ 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))]
@@ -52,6 +120,44 @@ 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;
@@ -77,13 +183,17 @@ public sealed partial class VersionRowViewModel : ObservableObject
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 => Strings.StatusInstalled,
VersionRowState.AvailableIdle => Strings.StatusAvailable,
VersionRowState.Downloading => Strings.StatusDownloading,
VersionRowState.Verifying => Strings.StatusVerifying,
VersionRowState.Installing => Strings.StatusInstalling,
VersionRowState.Uninstalling => Strings.StatusUninstalling,
_ => string.Empty
@@ -136,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
@@ -147,6 +270,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
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);
@@ -170,6 +294,15 @@ public sealed partial class VersionRowViewModel : ObservableObject
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)
=> 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

@@ -4,7 +4,9 @@
xmlns:loc="clr-namespace:PSLauncher.Core.Localization;assembly=PSLauncher.Core"
Title="License"
Icon="pack://application:,,,/Resources/favicon.ico"
Width="500" Height="480"
Width="540" Height="540"
SizeToContent="Height"
MinHeight="480"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Background="{StaticResource Brush.Bg.Window}">
@@ -16,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 -->

View File

@@ -66,7 +66,7 @@ public partial class LicenseDetailsDialog : Window
private void OnDeactivate(object sender, RoutedEventArgs e)
{
var confirm = MessageBox.Show(
var confirm = ThemedMessageBox.Show(
Strings.MsgDeactivateLicenseDetailedConfirm,
Strings.MsgBoxConfirm,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);

View File

@@ -4,12 +4,14 @@
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"
@@ -83,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>
@@ -113,6 +140,27 @@
<!-- 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="{x:Static loc:Strings.ActionLaunch}" Padding="22,8" FontSize="13"
Command="{Binding LaunchCommand}"
@@ -160,6 +208,8 @@
<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="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
@@ -172,19 +222,44 @@
</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 : limité au body uniquement (Grid.Row="1") pour ne pas
se faire chevaucher par le footer quand un download tourne. ImageBrush
avec AlignmentX=Right + AlignmentY=Bottom : si le ratio fenêtre force
un crop, on rogne en haut/à gauche — le logo ASTERION VR en bas-droite
reste TOUJOURS visible. -->
<Rectangle Grid.Row="1" IsHitTestVisible="False">
<!-- 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"
@@ -193,9 +268,13 @@
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>
@@ -315,8 +394,59 @@
</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">
@@ -390,6 +520,32 @@
<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>
@@ -422,13 +578,34 @@
</TextBlock>
<!-- Action principale (gros bouton) -->
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Style="{StaticResource PrimaryButton}"
<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>
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Orientation="Vertical" VerticalAlignment="Center"
@@ -484,6 +661,8 @@
<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="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
@@ -537,9 +716,11 @@
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.). -->
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,28"
Margin="0,0,0,8"
Background="#A0000000"
CornerRadius="10"
Padding="10,4">
@@ -548,10 +729,151 @@
Foreground="White" />
</Border>
</Grid>
<!-- ====================== END LIBRARY PAGE ====================== -->
<!-- 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}"
<!-- ====================== REPORT PAGE (WebView2) ====================== -->
<Grid Background="{StaticResource Brush.Bg.Window}"
Visibility="{Binding IsReport, Converter={StaticResource BoolToVisibility}}">
<wv2:WebView2 x:Name="ReportWebView"
Source="{Binding ReportUri, Mode=OneWay}" />
</Grid>
<!-- ====================== DOCUMENTATION PAGE ====================== -->
<Grid Background="{StaticResource Brush.Bg.Window}"
Visibility="{Binding IsDocumentation, Converter={StaticResource BoolToVisibility}}">
<!-- WebView2 si une URL est configurée, sinon un placeholder explicatif -->
<wv2:WebView2 x:Name="DocsWebView"
Source="{Binding DocumentationUri, Mode=OneWay}"
Visibility="{Binding HasDocumentationUrl, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{x:Static loc:Strings.DocsPlaceholder}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="14" TextAlignment="Center" TextWrapping="Wrap"
MaxWidth="500"
Visibility="{Binding HasDocumentationUrl, Converter={StaticResource InverseBoolToVisibility}}" />
</Grid>
</Grid>
<!-- ============== END Content area ============== -->
<!-- ============== Left sidebar : nav buttons ==============
Row 1 + RowSpan=3 → la sidebar s'étend du bas du top bar jusqu'au
bas de la fenêtre, couvrant les 3 rows (health banner, content,
footer). Visuellement la sidebar reste le « chapeau de tout »
côté gauche, peu importe ce qui se passe à droite. -->
<Border Grid.Row="1" Grid.RowSpan="3" Grid.Column="0"
Background="#0A0A0E"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="0,0,1,0">
<DockPanel LastChildFill="True">
<!-- Bloc d'info en bas de sidebar : version du launcher + IP locale.
Utilité diagnostic terrain : le support peut lire les deux infos
sans avoir à naviguer dans Windows ou About. DockPanel.Dock=Bottom
pour rester collé au bas peu importe la hauteur de la fenêtre. -->
<Border DockPanel.Dock="Bottom"
BorderBrush="{StaticResource Brush.Border}"
BorderThickness="0,1,0,0"
Padding="16,10">
<!-- Grid 2 colonnes : la colonne label (Auto) prend la largeur du
plus long ("PS_Launcher:"), la colonne valeur démarre au même X
sur les deux lignes → version et IP visuellement alignées. -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0"
Text="PS_Launcher"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,0,8,0"
ToolTip="Version du launcher" />
<TextBlock Grid.Row="0" Grid.Column="1"
Text="{Binding AppVersion}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" />
<TextBlock Grid.Row="1" Grid.Column="0"
Text="IP"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="IPv4 locale du PC (interface avec gateway par défaut)" />
<TextBlock Grid.Row="1" Grid.Column="1"
Text="{Binding LocalIpAddress}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
<!-- Statut LAN P2P, refresh auto toutes les 10 s par DispatcherTimer
dans MainViewModel. ON / OFF (—) / KO (config ON mais bind échoué). -->
<TextBlock Grid.Row="2" Grid.Column="0"
Text="Serveur"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="État du serveur LAN P2P (partage des ZIPs aux peers du LAN)" />
<TextBlock Grid.Row="2" Grid.Column="1"
Text="{Binding LanServerStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0"
Text="Client"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="Mode client : tente d'aller chercher les ZIPs sur le LAN avant OVH" />
<TextBlock Grid.Row="3" Grid.Column="1"
Text="{Binding LanClientStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0"
Text="Peers"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,8,0"
ToolTip="Nombre de peers LAN découverts auto via UDP (TTL 60 s)" />
<TextBlock Grid.Row="4" Grid.Column="1"
Text="{Binding LanPeerCount}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" Margin="0,2,0,0" />
</Grid>
</Border>
<StackPanel Orientation="Vertical" Margin="0,16,0,0">
<!-- Style des boutons définis dans Theme.xaml (NavButton) -->
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateLibraryCommand}"
Tag="{Binding IsLibrary}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📚" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavLibrary}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateReportCommand}"
Tag="{Binding IsReport}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📊" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavReport}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Style="{StaticResource NavButton}"
Command="{Binding NavigateDocumentationCommand}"
Tag="{Binding IsDocumentation}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="📖" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" />
<TextBlock Text="{x:Static loc:Strings.NavDocumentation}" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</DockPanel>
</Border>
<!-- Footer : Row 3, Col 1 → cantonné à la colonne content, sous la zone
Library/Report/Doc. La sidebar (RowSpan=2 sur rows 2-3) le contourne
par sa colonne. -->
<Border Grid.Row="3" Grid.Column="1"
Background="{StaticResource Brush.Bg.Footer}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="0,1,0,0"
Padding="20,10"
Visibility="{Binding FooterVisibility}">
@@ -574,7 +896,7 @@
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

@@ -3,6 +3,7 @@ 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;
@@ -25,11 +26,89 @@ public partial class MainWindow : Window
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>
@@ -42,7 +121,7 @@ public partial class MainWindow : Window
if (DataContext is MainViewModel vm && vm.HasActiveDownload)
{
var version = vm.ActiveDownloadVersion ?? "?";
var result = MessageBox.Show(
var result = ThemedMessageBox.Show(
Strings.MsgQuitWhileDownloadingConfirm(version),
Strings.MsgBoxQuit,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
@@ -73,6 +152,12 @@ public partial class MainWindow : Window
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;
}

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

@@ -142,6 +142,47 @@
</StackPanel>
</Border>
<!--
Section "À propos" : version + copyright. Visible (pas dans l'Expander),
placée entre Langue et les paramètres avancés repliés. C'est l'info que
le support demande en premier en cas de bug.
-->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsAbout}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock FontSize="13"
Foreground="{StaticResource Brush.Text.Primary}">
<Run Text="{x:Static loc:Strings.SettingsLauncherVersion}" />
<Run Text=" " />
<Run Text="{Binding LauncherVersion, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
<TextBlock Text="{x:Static loc:Strings.Copyright}"
FontSize="12"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0" />
</StackPanel>
</Border>
<!--
Sections "techniques" : Server / Installation / Cache / Database / Logs
repliées par défaut sous un Expander pour ne pas noyer les sections
importantes (License, Langue, About) qui restent dépliées.
Placé en BAS de la fenêtre car ce sont des trucs de power-user / support.
-->
<Expander x:Name="AdvancedExpander"
Header="{x:Static loc:Strings.SettingsAdvanced}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" FontWeight="Bold"
Background="Transparent" BorderThickness="0"
Margin="0,0,0,16" IsExpanded="False"
Expanded="OnAdvancedExpanded">
<StackPanel Margin="0,12,0,0">
<!-- Serveur -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
@@ -174,6 +215,36 @@
<TextBlock Text="{Binding ConnectionStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" />
<!-- URLs des onglets latéraux Report / Documentation -->
<TextBlock Text="{x:Static loc:Strings.SettingsReportUrl}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,16,0,4" />
<TextBox Text="{Binding ReportUrl, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<TextBlock Text="{x:Static loc:Strings.SettingsDocsUrl}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,12,0,4" />
<TextBox Text="{Binding DocumentationUrl, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<!-- Connexions parallèles : tuning de la queue de fin de DL -->
<TextBlock Text="{x:Static loc:Strings.SettingsParallelSegments}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,16,0,4" />
<TextBox Text="{Binding ParallelSegments, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
Width="80" HorizontalAlignment="Left" />
<TextBlock Text="{x:Static loc:Strings.SettingsParallelSegmentsHint}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" FontStyle="Italic" Margin="0,4,0,0"
TextWrapping="Wrap" />
</StackPanel>
</Border>
@@ -248,7 +319,862 @@
</StackPanel>
</Border>
<!-- Logs / About -->
<!-- Base de données (XAMPP / MySQL) — config + test + replay -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsDatabase}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,8,0">
<TextBlock Text="{x:Static loc:Strings.SettingsDbHost}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbHost, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="{x:Static loc:Strings.SettingsDbPort}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbPort, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</StackPanel>
</Grid>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Margin="0,0,8,0">
<TextBlock Text="{x:Static loc:Strings.SettingsDbUser}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbUser, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="{x:Static loc:Strings.SettingsDbPassword}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DbPassword, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
</StackPanel>
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsDbName}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding DbName, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<Button Grid.Column="1" Margin="8,0,0,0"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsTest}"
Command="{Binding TestDbCommand}"
IsEnabled="{Binding IsTestingDb, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<TextBlock Text="{Binding DbConnectionStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" />
<CheckBox IsChecked="{Binding DbAutoApplyMigrations}"
Margin="0,12,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsDbAutoMigrate}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding MigrationsReplayStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsDbReplay}"
Command="{Binding ReplayMigrationsCommand}"
IsEnabled="{Binding IsReplayingMigrations, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsDbHint}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontStyle="Italic" FontSize="11" Margin="0,8,0,0"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<!-- Outil Report (XAMPP htdocs deploy) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsReportTool}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportHtdocs}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding ReportHtdocsRoot, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportFolder}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,12,0,4" />
<TextBox Text="{Binding ReportFolderName, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<CheckBox IsChecked="{Binding ReportAutoDeploy}"
Margin="0,12,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsReportAutoDeploy}" />
<!-- Conserver N backups -->
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsReportMaxBackups}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding ReportMaxBackups, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</Grid>
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding ReportDeployStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsReportRedeploy}"
Command="{Binding RedeployReportCommand}"
IsEnabled="{Binding IsDeployingReport, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<!-- Liste des backups disponibles avec bouton Revert par ligne -->
<TextBlock Text="{x:Static loc:Strings.SettingsReportBackups}"
FontWeight="Bold" FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,16,0,6" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportNoBackups}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" FontStyle="Italic"
Visibility="{Binding HasReportBackups, Converter={StaticResource InverseBoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding ReportBackups}"
Visibility="{Binding HasReportBackups, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#1A1A20"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding DateDisplay}"
Foreground="{StaticResource Brush.Text.Primary}"
FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="{Binding SizeDisplay}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" />
</StackPanel>
<TextBlock Grid.Column="1"
Text="{Binding FolderName}"
FontFamily="Consolas" FontSize="10"
Foreground="{StaticResource Brush.Text.Secondary}"
VerticalAlignment="Center" Margin="0,0,12,0" />
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsReportRevert}"
Command="{Binding RevertCommand}"
Padding="12,6" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- API PHP de stats (XAMPP htdocs deploy, mirror of Report/Doc) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsApiTool}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportHtdocs}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding ApiHtdocsRoot, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportFolder}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,12,0,4" />
<TextBox Text="{Binding ApiFolderName, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<CheckBox IsChecked="{Binding ApiAutoDeploy}"
Margin="0,12,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsApiAutoDeploy}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsApiMaxBackups}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding ApiMaxBackups, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</Grid>
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding ApiDeployStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsApiRedeploy}"
Command="{Binding RedeployApiCommand}"
IsEnabled="{Binding IsDeployingApi, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsApiBackups}"
FontWeight="Bold" FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,16,0,6" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportNoBackups}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" FontStyle="Italic"
Visibility="{Binding HasApiBackups, Converter={StaticResource InverseBoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding ApiBackups}"
Visibility="{Binding HasApiBackups, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#1A1A20"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding DateDisplay}"
Foreground="{StaticResource Brush.Text.Primary}"
FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="{Binding SizeDisplay}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" />
</StackPanel>
<TextBlock Grid.Column="1"
Text="{Binding FolderName}"
FontFamily="Consolas" FontSize="10"
Foreground="{StaticResource Brush.Text.Secondary}"
VerticalAlignment="Center" Margin="0,0,12,0" />
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsReportRevert}"
Command="{Binding RevertCommand}"
Padding="12,6" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- SteamVR : merge _steamvr/steamvr.vrsettings → fichier user à l'install -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVr}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<CheckBox IsChecked="{Binding SteamVrAutoMerge}"
Margin="0,4,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsSteamVrAutoMerge}" />
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVrSettingsPath}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,16,0,4" TextWrapping="Wrap" />
<TextBox Text="{Binding SteamVrSettingsPathOverride, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<!-- Affiche le path canonique attendu si Steam est à l'emplacement
par défaut. Évite à l'opérateur d'avoir à chercher dans la doc. -->
<TextBlock FontSize="11" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0"
TextWrapping="Wrap">
<Run Text="Auto-détection standard : " />
<Run Text="{Binding SteamVrCanonicalPath, Mode=OneWay}"
FontFamily="Consolas" />
</TextBlock>
<TextBlock Text="{x:Static loc:Strings.SettingsSteamVrProcesses}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,16,0,4" TextWrapping="Wrap" />
<TextBox Text="{Binding SteamVrProcessesToKill, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas"
AcceptsReturn="True" TextWrapping="Wrap"
MinHeight="120" MaxHeight="200"
VerticalScrollBarVisibility="Auto" />
</StackPanel>
</Border>
<!-- Args par défaut : appliqués à CHAQUE lancement de PROSERVE, mode
auto ou pas. Utile pour des flags globaux (ex : -nosplash, -log,
-fps=90) qui doivent s'appliquer quelle que soit la version lancée
manuellement. Les args du mode auto (bloc suivant) s'ajoutent en
plus, uniquement quand la version courante est celle désignée AUTO. -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsLaunchArgs}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock Text="{x:Static loc:Strings.SettingsLaunchArgsHelp}"
FontSize="12"
Foreground="{StaticResource Brush.Text.Secondary}"
TextWrapping="Wrap"
Margin="0,0,0,10" />
<!-- Liste éditable identique à celle du mode auto : Key + Value + Remove. -->
<ItemsControl ItemsSource="{Binding DefaultLaunchArgs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding Key, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas"
Tag="{x:Static loc:Strings.SettingsAutoModeArgKey}"
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgKey}" />
<TextBox Grid.Column="1"
Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas"
Margin="6,0,6,0"
Tag="{x:Static loc:Strings.SettingsAutoModeArgValue}"
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgValue}" />
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsAutoModeArgRemove}"
Command="{Binding RemoveCommand}"
Padding="10,6" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsAutoModeArgAdd}"
Command="{Binding AddDefaultLaunchArgCommand}"
HorizontalAlignment="Left"
Margin="0,4,0,0" Padding="12,6" />
</StackPanel>
</Border>
<!-- Mode auto : auto-launch + auto-relaunch d'une version désignée -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsAutoMode}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<CheckBox IsChecked="{Binding AutoModeFeatureEnabled}"
Margin="0,4,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsAutoModeFeatureEnabled}" />
<CheckBox IsChecked="{Binding AutoModeWaitForHealthChecks}"
Margin="0,8,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsAutoModeWaitForHealth}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsAutoModeGrace}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding AutoModeGraceSeconds, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</Grid>
<TextBlock Margin="0,12,0,4"
Text="{Binding AutoModeSelectedVersion, StringFormat='Version désignée actuelle : v{0}', FallbackValue='Aucune version désignée'}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" FontStyle="Italic" />
<TextBlock Text="{x:Static loc:Strings.SettingsAutoModeArgs}"
FontWeight="Bold" FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,16,0,6" TextWrapping="Wrap" />
<!-- Liste éditable d'arguments. Chaque ligne : Key + Value (optionnel) + Remove. -->
<ItemsControl ItemsSource="{Binding AutoModeArgs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding Key, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas"
Tag="{x:Static loc:Strings.SettingsAutoModeArgKey}"
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgKey}" />
<TextBox Grid.Column="1"
Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas"
Margin="6,0,6,0"
Tag="{x:Static loc:Strings.SettingsAutoModeArgValue}"
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgValue}" />
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsAutoModeArgRemove}"
Command="{Binding RemoveCommand}"
Padding="10,6" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsAutoModeArgAdd}"
Command="{Binding AddAutoModeArgCommand}"
HorizontalAlignment="Left"
Margin="0,4,0,0" Padding="12,6" />
</StackPanel>
</Border>
<!-- Cache LAN P2P (peers du LAN partagent les ZIPs entre eux) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsLanCache}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<CheckBox IsChecked="{Binding LanServerEnabled}"
Margin="0,4,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsLanCacheServerEnabled}" />
<CheckBox IsChecked="{Binding LanClientEnabled}"
Margin="0,4,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsLanCacheClientEnabled}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsLanCacheServerPort}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding LanServerPort, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
<TextBlock Grid.Column="3"
Text="{x:Static loc:Strings.SettingsLanCacheDiscoveryPort}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="4"
Text="{Binding LanDiscoveryPort, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</Grid>
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsLanCacheMaxCached}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding LanMaxCachedVersions, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</Grid>
<!-- Status serveur (read-only, alimenté par RefreshLanStatus) -->
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding LanServerStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="↻"
Command="{Binding RefreshLanStatusCommand}"
ToolTip="Actualiser le statut + la liste des peers"
Padding="12,6" />
</Grid>
<!-- Peers découverts auto (read-only) -->
<TextBlock Text="{x:Static loc:Strings.SettingsLanCacheDiscoveredPeers}"
FontWeight="Bold" FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,16,0,6" />
<TextBlock Text="{x:Static loc:Strings.SettingsLanCacheNoDiscovered}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" FontStyle="Italic"
Visibility="{Binding HasLanDiscoveredPeers, Converter={StaticResource InverseBoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding LanDiscoveredPeers}"
Visibility="{Binding HasLanDiscoveredPeers, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#1A1A20"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="4" Padding="10,6" Margin="0,0,0,4">
<TextBlock Text="{Binding}"
Foreground="{StaticResource Brush.Text.Primary}"
FontFamily="Consolas" FontSize="11" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Peers manuels (1 URL par ligne, multiline TextBox) -->
<TextBlock Text="{x:Static loc:Strings.SettingsLanCacheManualPeers}"
FontWeight="Bold" FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,16,0,6" />
<TextBox Text="{Binding LanManualPeers, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" FontSize="11"
AcceptsReturn="True"
TextWrapping="NoWrap"
VerticalScrollBarVisibility="Auto"
MinHeight="60" />
</StackPanel>
</Border>
<!-- Outil Doc (XAMPP htdocs deploy, mirror of Report) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsDocTool}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportHtdocs}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,0,0,4" />
<TextBox Text="{Binding DocHtdocsRoot, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportFolder}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" Margin="0,12,0,4" />
<TextBox Text="{Binding DocFolderName, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas" />
<CheckBox IsChecked="{Binding DocAutoDeploy}"
Margin="0,12,0,0"
Foreground="{StaticResource Brush.Text.Primary}"
Content="{x:Static loc:Strings.SettingsDocAutoDeploy}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="80" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsDocMaxBackups}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Text="{Binding DocMaxBackups, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8" />
</Grid>
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding DocDeployStatus}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" VerticalAlignment="Center"
TextWrapping="Wrap" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsDocRedeploy}"
Command="{Binding RedeployDocCommand}"
IsEnabled="{Binding IsDeployingDoc, Converter={StaticResource InverseBoolToBool}}" />
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsDocBackups}"
FontWeight="Bold" FontSize="11"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,16,0,6" />
<TextBlock Text="{x:Static loc:Strings.SettingsReportNoBackups}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="12" FontStyle="Italic"
Visibility="{Binding HasDocBackups, Converter={StaticResource InverseBoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding DocBackups}"
Visibility="{Binding HasDocBackups, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#1A1A20"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" VerticalAlignment="Center">
<TextBlock Text="{Binding DateDisplay}"
Foreground="{StaticResource Brush.Text.Primary}"
FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="{Binding SizeDisplay}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" />
</StackPanel>
<TextBlock Grid.Column="1"
Text="{Binding FolderName}"
FontFamily="Consolas" FontSize="10"
Foreground="{StaticResource Brush.Text.Secondary}"
VerticalAlignment="Center" Margin="0,0,12,0" />
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsReportRevert}"
Command="{Binding RevertCommand}"
Padding="12,6" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Bandeau de santé système : liste éditable des checks -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{x:Static loc:Strings.SettingsHealthChecks}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthAdd}"
Command="{Binding AddHealthCheckCommand}"
Padding="12,6" />
</Grid>
<TextBlock Text="{x:Static loc:Strings.SettingsHealthChecksHint}"
FontSize="11" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,8,0,8" TextWrapping="Wrap" />
<TextBlock Text="{x:Static loc:Strings.SettingsHealthEmpty}"
FontSize="12" FontStyle="Italic"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0" TextWrapping="Wrap"
Visibility="{Binding HasHealthChecks, Converter={StaticResource InverseBoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding HealthChecks}"
Visibility="{Binding HasHealthChecks, Converter={StaticResource BoolToVisibility}}"
Margin="0,4,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#1A1A20"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="4" Padding="10,8" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding DisplayIcon}"
FontSize="20"
VerticalAlignment="Center"
Margin="2,0,12,0" />
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Text="{Binding DisplayName}"
Foreground="{StaticResource Brush.Text.Primary}"
FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="{Binding DisplayDetail}"
Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11"
FontFamily="Consolas" />
</StackPanel>
<!-- Boutons ▲/▼ pour réordonner. CanExecute géré côté
ViewModel : grisés sur le premier/dernier item. -->
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="▲"
Command="{Binding MoveUpCommand}"
ToolTip="Monter dans la liste"
Margin="0,0,4,0"
Padding="10,6" />
<Button Grid.Column="3"
Style="{StaticResource SecondaryButton}"
Content="▼"
Command="{Binding MoveDownCommand}"
ToolTip="Descendre dans la liste"
Margin="0,0,12,0"
Padding="10,6" />
<Button Grid.Column="4"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthEdit}"
Command="{Binding EditCommand}"
Margin="0,0,6,0"
Padding="12,6" />
<Button Grid.Column="5"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthDelete}"
Command="{Binding DeleteCommand}"
Padding="12,6" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Logs (path + open button) -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
@@ -257,17 +1183,7 @@
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock FontSize="13"
Foreground="{StaticResource Brush.Text.Primary}">
<Run Text="{x:Static loc:Strings.SettingsLauncherVersion}" />
<Run Text=" " />
<Run Text="{Binding LauncherVersion, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
<TextBlock Text="{x:Static loc:Strings.Copyright}"
FontSize="12"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,4,0,0" />
<Grid Margin="0,8,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -290,6 +1206,9 @@
</StackPanel>
</Border>
</StackPanel>
</Expander>
</StackPanel>
</ScrollViewer>

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

@@ -14,6 +14,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
@@ -37,7 +38,16 @@
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="{x:Static loc:Strings.ActionLater}"
IsCancel="True"

View File

@@ -24,9 +24,18 @@ public partial class UpdateAvailableDialog : Window
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();
}

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

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

@@ -62,7 +62,11 @@ public sealed class DownloadManager : IDownloadManager
_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),
@@ -176,7 +180,10 @@ public sealed class DownloadManager : IDownloadManager
}
// 2. Choix single vs multi-segment
var requestedSegments = Math.Clamp(_config.ParallelDownloadSegments, 1, 16);
// Hard-cap à 32 : au-delà OVH mutualisé risque le rate-limit per-IP et le
// gain marginal devient négatif. 16 = défaut, 24-32 OK pour les serveurs
// qui le supportent. 1 = comportement single-segment legacy.
var requestedSegments = Math.Clamp(_config.ParallelDownloadSegments, 1, 32);
var useMultiSegment = requestedSegments > 1
&& job.ExpectedSize >= MultiSegmentMinSize
&& (existing is null || existing.Segments.Count > 0);
@@ -291,7 +298,12 @@ public sealed class DownloadManager : IDownloadManager
// 4. Lance les workers en parallèle.
// Compteur agrégé via Interlocked → pas de lock dans le hot path.
long aggregateBytes = state.DownloadedBytes;
// 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)
@@ -300,14 +312,26 @@ public sealed class DownloadManager : IDownloadManager
// 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 lastReport = TimeSpan.Zero;
long lastReportBytes = Interlocked.Read(ref aggregateBytes);
var lastFlush = TimeSpan.Zero;
long lastFlushBytes = lastReportBytes;
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)
@@ -315,13 +339,24 @@ public sealed class DownloadManager : IDownloadManager
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
var snapshot = Interlocked.Read(ref aggregateBytes);
var elapsed = sw.Elapsed;
var deltaSec = (elapsed - lastReport).TotalSeconds;
var bps = deltaSec > 0 ? (snapshot - lastReportBytes) / deltaSec : 0;
// 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);
if (bps > 0 && total > snapshot)
eta = TimeSpan.FromSeconds((total - snapshot) / bps);
progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
lastReport = elapsed;
lastReportBytes = snapshot;
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
snapshot - lastFlushBytes >= StateFlushBytesInterval)
@@ -366,7 +401,22 @@ public sealed class DownloadManager : IDownloadManager
_stateStore.Save(state);
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, TimeSpan.Zero));
// 5. Vérif taille
// 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)
{
@@ -428,16 +478,84 @@ public sealed class DownloadManager : IDownloadManager
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
}
// 403 / 410 = URL HMAC expirée (gate.php renvoie 403 "Forbidden: expired").
// On force un refresh d'URL via le callback du job, puis on jette une
// exception transitoire pour que Polly retry avec la nouvelle URL.
if (resp.StatusCode == HttpStatusCode.Forbidden || resp.StatusCode == HttpStatusCode.Gone)
{
if (job.RefreshUrlAsync is not null)
// 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);
throw new HttpResumableException("Signed URL expired, refreshed for next try", isTransient: true);
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)
{
@@ -452,16 +570,67 @@ public sealed class DownloadManager : IDownloadManager
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);
seg.DownloadedBytes += n;
onBytes(seg.Index, n);
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;
}
await dst.FlushAsync(ct).ConfigureAwait(false);
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);
}
}
// ===================================================================

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,11 +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";
/// <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).
[GeneratedRegex(@"^PROSERVE v(?<v>\d+\.\d+\.\d+)$", RegexOptions.IgnoreCase)]
// 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;
@@ -38,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;
}
@@ -51,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
@@ -59,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

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

View File

@@ -0,0 +1,62 @@
namespace PSLauncher.Core.Lan;
/// <summary>
/// URL d'un peer LAN qui a déjà la version demandée et a passé la vérification
/// (taille + SHA-256 du manifest match la réponse <c>/v1/has/</c>). À utiliser
/// dans le DownloadJob comme URL initiale au lieu de l'URL OVH.
/// </summary>
public sealed record PeerSource(Uri ZipUrl, string PeerHost);
/// <summary>
/// Helper interne aux probers (PeerSourceResolver, PeerManifestFetcher) pour
/// fusionner peers actifs (beacons UDP récents), peers connus (cache disque),
/// peers manuels (config). Évite la duplication entre les 2 fetchers.
/// </summary>
internal static class PeerCandidates
{
public static List<string> Build(ILanDiscoveryService discovery, IEnumerable<string> manualUrls)
{
var candidates = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// 1) Peers actifs (UDP beacon < 60s) — les plus fiables
foreach (var p in discovery.ListDiscovered())
{
var url = $"http://{p.Host}:{p.Port}";
if (seen.Add(url)) candidates.Add(url);
}
// 2) Peers connus (cache disque, runs précédents) — essayés rapidement même
// si pas encore reçu de beacon ce run (cold start, < 15s d'uptime)
foreach (var p in discovery.ListKnown())
{
var url = $"http://{p.Host}:{p.Port}";
if (seen.Add(url)) candidates.Add(url);
}
// 3) Peers manuels (config user) — fallback / override explicite
foreach (var url in manualUrls)
{
var trimmed = url.Trim().TrimEnd('/');
if (string.IsNullOrEmpty(trimmed)) continue;
if (seen.Add(trimmed)) candidates.Add(trimmed);
}
return candidates;
}
}
/// <summary>
/// Trouve un peer LAN qui a la version demandée. Combine peers découverts auto
/// (UDP discovery) et peers manuels (config), probe chacun avec un timeout court,
/// retourne le premier qui matche le SHA-256 attendu. Null si aucun → fallback OVH.
///
/// Budget total : <c>ProbeTimeoutMs</c> × max 5 peers = ~5 s pire cas. Ne ralentit
/// jamais le path OVH au-delà.
/// </summary>
public interface IPeerSourceResolver
{
Task<PeerSource?> TryResolveAsync(
string version,
long expectedSize,
string expectedSha256,
CancellationToken ct);
}

View File

@@ -0,0 +1,64 @@
namespace PSLauncher.Core.Lan;
/// <summary>
/// Une entrée du cache local des ZIPs PROSERVE déjà téléchargés. Le SHA-256 est
/// stocké dans un fichier sidecar <c>.sha256</c> à côté du ZIP — calculé une seule
/// fois au moment du Promote (déjà connu via le manifest signé), pas re-hashé à
/// chaque ListCached() (un md5 sur 14 Go prendrait 30s-3min).
/// </summary>
public sealed record CachedZip(string Version, string Path, long SizeBytes, string Sha256, DateTime ModifiedUtc);
/// <summary>
/// Cache local des ZIPs PROSERVE. Sert deux usages :
/// 1. Permet à un PC en mode serveur LAN de servir les ZIPs aux peers du LAN.
/// 2. Permet le rollback ou la réinstall instantanée d'une version sans re-DL.
///
/// Stockage : <c>%LocalAppData%\PSLauncher\downloads\proserve-{version}.zip</c>
/// (mêmechemin que les .partial du DownloadStateStore, le ZIP final y atterrit
/// déjà après VerifyAndFinalizeAsync). Le sidecar <c>.sha256</c> contient le
/// hash en hex pour servir les peers sans re-calcul.
///
/// Pruning : conserve les <c>keepLatest</c> plus récents par mtime, MAIS ne
/// supprime jamais un ZIP dont la version est encore installée (cf.
/// <see cref="PSLauncher.Core.Installations.IInstallationRegistry.Scan"/>).
/// </summary>
public interface IZipCacheStore
{
/// <summary>Chemin canonique du ZIP pour cette version (qu'il existe ou non).</summary>
string GetCachedZipPath(string version);
/// <summary>Vrai si on a un ZIP + sidecar SHA-256 valide pour cette version.</summary>
bool TryGetCachedEntry(string version, out CachedZip entry);
/// <summary>
/// Promeut un ZIP final (post-VerifyAndFinalizeAsync) au statut "caché".
/// Si <paramref name="srcPath"/> est déjà au bon endroit (= GetCachedZipPath),
/// no-op sur le fichier — écrit juste le sidecar SHA. Sinon, déplace.
/// </summary>
Task PromoteAsync(string srcPath, string version, string sha256, CancellationToken ct);
/// <summary>
/// Supprime les ZIPs hors top-N par mtime. Skip silencieusement les versions
/// encore installées localement (peu importe leur âge).
/// </summary>
Task PruneAsync(int keepLatest, CancellationToken ct);
/// <summary>Liste tous les ZIPs cachés valides (avec sidecar SHA présent).</summary>
IReadOnlyList<CachedZip> ListCached();
/// <summary>
/// Supprime AGRESSIVEMENT le ZIP + sidecar SHA d'une version donnée du cache
/// local, peu importe son âge ou si la version est encore installée. Utilisé
/// dans deux cas :
/// <list type="number">
/// <item>Échec de vérif SHA après DL : on évacue l'éventuel ZIP cache de
/// la version concernée pour ne pas servir du contenu obsolète aux peers
/// LAN (et pour forcer un re-DL complet au prochain Install).</item>
/// <item>Action manuelle "Forcer re-téléchargement" depuis le menu UI :
/// l'opérateur veut purger explicitement (typiquement quand la source
/// OVH est en cache CDN stale et qu'il a besoin d'un re-DL frais).</item>
/// </list>
/// Best-effort : log les erreurs I/O mais ne throw pas.
/// </summary>
Task InvalidateAsync(string version, CancellationToken ct);
}

View File

@@ -0,0 +1,466 @@
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Manifests;
using PSLauncher.Models;
namespace PSLauncher.Core.Lan;
/// <summary>
/// Serveur HTTP local pour le partage de ZIPs LAN, basé sur <see cref="TcpListener"/>.
///
/// Pourquoi pas <c>HttpListener</c> ? Parce que <c>HttpListener.Start()</c> exige une
/// URL ACL admin pour TOUT prefix qui n'est pas <c>localhost</c> — y compris quand on
/// bind sur une IP spécifique (j'avais cru le contraire et c'était faux). Avec
/// <c>TcpListener</c>, n'importe quel utilisateur peut bind sur <c>0.0.0.0:port</c>
/// sans URL ACL, sans installer le moindre handler système. On parse HTTP à la main
/// (request line + headers + Range), c'est ~80 LOC et le protocole est trivial pour
/// nos 2 routes en GET.
///
/// Sécurité (inchangée) :
/// - Filtre RFC1918 / link-local sur RemoteEndPoint avant tout File.IO
/// - Validation regex stricte du path version (anti-path-traversal)
/// - SemaphoreSlim(4) pour rate-limit
/// </summary>
public sealed class LanCacheServer : BackgroundService, ILanCacheServer
{
private const int MaxConcurrentServes = 4;
private const int CopyBufferSize = 1 << 16; // 64 KiB
private const int HeaderReadTimeoutMs = 5_000;
private static readonly Regex VersionRegex = new(@"^[0-9A-Za-z._-]+$", RegexOptions.Compiled);
private readonly Func<LanCacheConfig> _configProvider;
private readonly IZipCacheStore _cache;
private readonly IManifestCache _manifestCache;
private readonly ILogger<LanCacheServer> _logger;
private readonly SemaphoreSlim _serveLimit = new(MaxConcurrentServes, MaxConcurrentServes);
private TcpListener? _listener;
private List<string> _boundEndpoints = new();
private int? _boundPort;
public LanCacheServer(
Func<LanCacheConfig> configProvider,
IZipCacheStore cache,
IManifestCache manifestCache,
ILogger<LanCacheServer> logger)
{
_configProvider = configProvider;
_cache = cache;
_manifestCache = manifestCache;
_logger = logger;
}
public bool IsRunning => _listener?.Server.IsBound == true;
public int? BoundPort => _boundPort;
public IReadOnlyList<string> BoundEndpoints => _boundEndpoints;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var cfg = _configProvider();
if (!cfg.ServerEnabled)
{
_logger.LogInformation("LAN cache server disabled (ServerEnabled=false)");
return;
}
try
{
_listener = new TcpListener(IPAddress.Any, cfg.ServerPort);
_listener.Start();
_boundPort = cfg.ServerPort;
// Liste informative (affichée dans Settings UI). Le bind réel est
// sur 0.0.0.0 donc toutes les interfaces, mais on n'affiche que les
// IPs LAN — c'est elles que les peers vont contacter.
_boundEndpoints = GetLocalLanIPv4()
.Select(ip => $"http://{ip}:{cfg.ServerPort}")
.ToList();
if (_boundEndpoints.Count == 0)
_boundEndpoints.Add($"http://0.0.0.0:{cfg.ServerPort}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start LAN cache server on port {Port}. Le port est-il déjà utilisé ?", cfg.ServerPort);
try { _listener?.Stop(); } catch { }
_listener = null;
return;
}
_logger.LogInformation("LAN cache server listening on TCP :{Port} ({Endpoints})",
cfg.ServerPort, string.Join(", ", _boundEndpoints));
while (!stoppingToken.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener.AcceptTcpClientAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) { break; }
catch (ObjectDisposedException) { break; }
catch (Exception ex)
{
_logger.LogDebug(ex, "AcceptTcpClient failed (will retry)");
continue;
}
// Une task par connexion, fire-and-forget.
_ = Task.Run(() => HandleConnectionAsync(client, stoppingToken), stoppingToken);
}
try { _listener.Stop(); } catch { }
_listener = null;
_logger.LogInformation("LAN cache server stopped");
}
private async Task HandleConnectionAsync(TcpClient client, CancellationToken ct)
{
try
{
var remoteIp = (client.Client.RemoteEndPoint as IPEndPoint)?.Address;
using (client)
await using (var stream = client.GetStream())
{
// 1) Filtre RFC1918 sur l'IP source. Defense in depth.
if (remoteIp is null || !IsRfc1918OrLinkLocal(remoteIp))
{
_logger.LogWarning("Rejecting non-LAN request from {Remote}", remoteIp);
await WriteSimpleResponseAsync(stream, 403, "forbidden: LAN-only", ct).ConfigureAwait(false);
return;
}
// 2) Lit la request line + headers (timeout 5s pour une connexion qui
// se connecte sans rien envoyer — anti-slow-loris basique).
using var headerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
headerCts.CancelAfter(HeaderReadTimeoutMs);
var request = await ReadRequestAsync(stream, headerCts.Token).ConfigureAwait(false);
if (request is null)
{
await WriteSimpleResponseAsync(stream, 400, "bad request", ct).ConfigureAwait(false);
return;
}
// 3) Routing : seul GET, sur /v1/has/{ver} ou /v1/zip/{ver}
if (!string.Equals(request.Method, "GET", StringComparison.OrdinalIgnoreCase))
{
await WriteSimpleResponseAsync(stream, 405, "method not allowed", ct).ConfigureAwait(false);
return;
}
const string hasPrefix = "/v1/has/";
const string zipPrefix = "/v1/zip/";
const string manifestPath = "/v1/manifest";
const string infoPath = "/v1/info";
if (request.Path.StartsWith(hasPrefix, StringComparison.Ordinal))
{
var version = request.Path.Substring(hasPrefix.Length);
await HandleHasAsync(stream, version, ct).ConfigureAwait(false);
}
else if (request.Path.StartsWith(zipPrefix, StringComparison.Ordinal))
{
var version = request.Path.Substring(zipPrefix.Length);
await HandleZipAsync(stream, version, request.RangeHeader, ct).ConfigureAwait(false);
}
else if (request.Path == manifestPath)
{
await HandleManifestAsync(stream, ct).ConfigureAwait(false);
}
else if (request.Path == infoPath)
{
await HandleInfoAsync(stream, ct).ConfigureAwait(false);
}
else
{
await WriteSimpleResponseAsync(stream, 404, "not found", ct).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error handling LAN cache connection");
}
}
private async Task HandleHasAsync(NetworkStream stream, string version, CancellationToken ct)
{
if (!VersionRegex.IsMatch(version))
{
await WriteSimpleResponseAsync(stream, 400, "invalid version", ct).ConfigureAwait(false);
return;
}
if (!_cache.TryGetCachedEntry(version, out var entry))
{
await WriteSimpleResponseAsync(stream, 404, "not in cache", ct).ConfigureAwait(false);
return;
}
var json = JsonSerializer.Serialize(new
{
sha256 = entry.Sha256,
size = entry.SizeBytes,
mtime = entry.ModifiedUtc,
});
var body = Encoding.UTF8.GetBytes(json);
await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false);
}
/// <summary>
/// Endpoint léger d'auto-discovery active. Retourne la même structure que
/// le beacon UDP : <c>{v, host, port, versions}</c>. Sert au cold-start
/// d'un client qui veut probe ses peers connus du cache disque ou manuels
/// SANS attendre les 15 s du prochain beacon UDP.
/// </summary>
private async Task HandleInfoAsync(NetworkStream stream, CancellationToken ct)
{
var versions = _cache.ListCached().Select(c => c.Version).ToList();
var json = JsonSerializer.Serialize(new
{
v = 1,
host = (_boundEndpoints.Count > 0 ? _boundEndpoints[0] : $"http://0.0.0.0:{_boundPort}")
.Replace("http://", "").Split(':')[0],
port = _boundPort ?? 0,
versions,
});
var body = Encoding.UTF8.GetBytes(json);
await WriteResponseAsync(stream, 200, "OK", "application/json", body.LongLength, null, body, ct).ConfigureAwait(false);
}
/// <summary>
/// Sert le manifest cached byte-for-byte aux peers du LAN qui n'ont pas
/// d'accès Internet. Le client validera la signature Ed25519 du manifest
/// (trust chain inchangée). 404 si pas de cache (le serveur PC n'a jamais
/// fetché OVH avec succès).
/// </summary>
private async Task HandleManifestAsync(NetworkStream stream, CancellationToken ct)
{
var path = _manifestCache.GetCachedFilePath();
if (!File.Exists(path))
{
await WriteSimpleResponseAsync(stream, 404, "no manifest cached", ct).ConfigureAwait(false);
return;
}
try
{
var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
await WriteResponseAsync(stream, 200, "OK", "application/json", bytes.LongLength, null, bytes, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to serve cached manifest");
await WriteSimpleResponseAsync(stream, 500, "manifest read error", ct).ConfigureAwait(false);
}
}
private async Task HandleZipAsync(NetworkStream stream, string version, string? rangeHeader, CancellationToken ct)
{
if (!VersionRegex.IsMatch(version))
{
await WriteSimpleResponseAsync(stream, 400, "invalid version", ct).ConfigureAwait(false);
return;
}
if (!_cache.TryGetCachedEntry(version, out var entry))
{
await WriteSimpleResponseAsync(stream, 404, "not in cache", ct).ConfigureAwait(false);
return;
}
if (!await _serveLimit.WaitAsync(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false))
{
await WriteSimpleResponseAsync(stream, 503, "server busy, try later", ct).ConfigureAwait(false);
return;
}
try
{
await using var fs = new FileStream(entry.Path, FileMode.Open, FileAccess.Read, FileShare.Read,
CopyBufferSize, useAsync: true);
long total = fs.Length;
long start = 0;
long end = total - 1;
bool isPartial = false;
if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
{
var spec = rangeHeader.Substring("bytes=".Length);
var parts = spec.Split('-', 2);
if (parts.Length == 2 && long.TryParse(parts[0], out var s) && s >= 0 && s < total)
{
start = s;
if (parts[1].Length > 0 && long.TryParse(parts[1], out var e) && e >= start && e < total)
end = e;
isPartial = true;
}
else
{
var headers = $"Content-Range: bytes */{total}\r\n";
await WriteSimpleResponseAsync(stream, 416, "range not satisfiable", ct, headers).ConfigureAwait(false);
return;
}
}
var length = end - start + 1;
int code = isPartial ? 206 : 200;
string status = isPartial ? "Partial Content" : "OK";
string? extraHeaders = null;
if (isPartial) extraHeaders = $"Content-Range: bytes {start}-{end}/{total}\r\n";
// Headers : Content-Type + Length + Accept-Ranges + ETag + (Content-Range si 206) + Connection: close
var sb = new StringBuilder();
sb.Append("HTTP/1.1 ").Append(code).Append(' ').Append(status).Append("\r\n");
sb.Append("Content-Type: application/zip\r\n");
sb.Append("Content-Length: ").Append(length).Append("\r\n");
sb.Append("Accept-Ranges: bytes\r\n");
sb.Append("ETag: \"").Append(entry.Sha256).Append("\"\r\n");
if (extraHeaders is not null) sb.Append(extraHeaders);
sb.Append("Connection: close\r\n\r\n");
var headerBytes = Encoding.ASCII.GetBytes(sb.ToString());
await stream.WriteAsync(headerBytes, ct).ConfigureAwait(false);
// Body : stream le fichier en chunks
fs.Seek(start, SeekOrigin.Begin);
var buffer = new byte[CopyBufferSize];
long remaining = length;
while (remaining > 0)
{
ct.ThrowIfCancellationRequested();
int toRead = (int)Math.Min(buffer.Length, remaining);
int n = await fs.ReadAsync(buffer.AsMemory(0, toRead), ct).ConfigureAwait(false);
if (n <= 0) break;
await stream.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
remaining -= n;
}
}
finally
{
_serveLimit.Release();
}
}
/// <summary>
/// Parser HTTP minimal : juste la request line + le header Range (le seul qu'on
/// utilise). Les autres headers sont lus mais ignorés. Limite : 8 KiB de headers
/// max, suffisamment pour notre cas (typique 200-500 octets).
/// </summary>
private static async Task<ParsedRequest?> ReadRequestAsync(NetworkStream stream, CancellationToken ct)
{
var buffer = new byte[8192];
int total = 0;
int headerEnd = -1;
while (total < buffer.Length)
{
int n = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), ct).ConfigureAwait(false);
if (n <= 0) break;
total += n;
// Cherche \r\n\r\n (fin des headers HTTP)
for (int i = 3; i < total; i++)
{
if (buffer[i - 3] == (byte)'\r' && buffer[i - 2] == (byte)'\n'
&& buffer[i - 1] == (byte)'\r' && buffer[i] == (byte)'\n')
{
headerEnd = i + 1;
break;
}
}
if (headerEnd >= 0) break;
}
if (headerEnd < 0) return null;
var raw = Encoding.ASCII.GetString(buffer, 0, headerEnd);
var lines = raw.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
if (lines.Length == 0) return null;
// Ligne 1 : "GET /path HTTP/1.1"
var first = lines[0].Split(' ', 3);
if (first.Length < 3) return null;
var method = first[0];
var path = first[1];
string? rangeHeader = null;
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i];
var colon = line.IndexOf(':');
if (colon <= 0) continue;
var name = line.Substring(0, colon).Trim();
var value = line.Substring(colon + 1).Trim();
if (string.Equals(name, "Range", StringComparison.OrdinalIgnoreCase))
rangeHeader = value;
}
return new ParsedRequest(method, path, rangeHeader);
}
private sealed record ParsedRequest(string Method, string Path, string? RangeHeader);
private static Task WriteSimpleResponseAsync(NetworkStream stream, int code, string text, CancellationToken ct, string? extraHeaders = null)
{
var body = Encoding.UTF8.GetBytes(text);
return WriteResponseAsync(stream, code, ReasonPhrase(code), "text/plain; charset=utf-8", body.LongLength, extraHeaders, body, ct);
}
private static async Task WriteResponseAsync(NetworkStream stream, int code, string status, string contentType, long contentLength, string? extraHeaders, byte[]? body, CancellationToken ct)
{
var sb = new StringBuilder();
sb.Append("HTTP/1.1 ").Append(code).Append(' ').Append(status).Append("\r\n");
sb.Append("Content-Type: ").Append(contentType).Append("\r\n");
sb.Append("Content-Length: ").Append(contentLength).Append("\r\n");
if (extraHeaders is not null) sb.Append(extraHeaders);
sb.Append("Connection: close\r\n\r\n");
var headerBytes = Encoding.ASCII.GetBytes(sb.ToString());
await stream.WriteAsync(headerBytes, ct).ConfigureAwait(false);
if (body is not null && body.Length > 0)
await stream.WriteAsync(body, ct).ConfigureAwait(false);
}
private static string ReasonPhrase(int code) => code switch
{
200 => "OK",
206 => "Partial Content",
400 => "Bad Request",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
416 => "Range Not Satisfiable",
500 => "Internal Server Error",
503 => "Service Unavailable",
_ => "Status",
};
/// <summary>
/// Filtre RFC1918 + link-local (169.254.x.x) + loopback. Pas de bypass
/// possible : c'est ce qui garantit qu'aucun ZIP ne sortira accidentellement
/// du LAN même si la conf firewall est désactivée.
/// </summary>
private static bool IsRfc1918OrLinkLocal(IPAddress addr)
{
if (IPAddress.IsLoopback(addr)) return true;
if (addr.AddressFamily != AddressFamily.InterNetwork) return false;
var b = addr.GetAddressBytes();
if (b[0] == 10) return true;
if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true;
if (b[0] == 192 && b[1] == 168) return true;
if (b[0] == 169 && b[1] == 254) return true;
return false;
}
private static IEnumerable<IPAddress> GetLocalLanIPv4()
{
return NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up
&& n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork
&& IsRfc1918OrLinkLocal(a.Address))
.Select(a => a.Address);
}
}

View File

@@ -0,0 +1,367 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Configuration;
using PSLauncher.Models;
namespace PSLauncher.Core.Lan;
public sealed class LanDiscoveryService : BackgroundService, ILanDiscoveryService
{
private const int BeaconIntervalMs = 15_000;
private const int PeerTtlSeconds = 60;
/// <summary>TTL des peers persistés sur disque entre deux runs du launcher.</summary>
private const int KnownPeerStaleHours = 168; // 1 semaine
private const string KnownPeersFileName = "lan-peers.json";
private readonly Func<LanCacheConfig> _configProvider;
private readonly IZipCacheStore _cache;
private readonly IConfigStore _configStore;
private readonly HttpClient _http;
private readonly ILogger<LanDiscoveryService> _logger;
private readonly ConcurrentDictionary<string, DiscoveredPeer> _peers = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Peers pré-chargés depuis le disque au démarrage. Pas de TTL court — ils
/// servent de "seed" pour ne pas attendre les 15s du premier beacon UDP.
/// PeerSourceResolver / PeerManifestFetcher les utilisent en plus de ListDiscovered().</summary>
private List<DiscoveredPeer> _knownPeers = new();
private string? _ourLanIp;
private DateTime _lastDiskFlush = DateTime.MinValue;
public LanDiscoveryService(
Func<LanCacheConfig> configProvider,
IZipCacheStore cache,
IConfigStore configStore,
HttpClient http,
ILogger<LanDiscoveryService> logger)
{
_configProvider = configProvider;
_cache = cache;
_configStore = configStore;
_http = http;
_logger = logger;
}
public IReadOnlyList<DiscoveredPeer> ListDiscovered()
{
var cutoff = DateTime.UtcNow.AddSeconds(-PeerTtlSeconds);
return _peers.Values.Where(p => p.LastSeenUtc >= cutoff).ToList();
}
/// <summary>
/// Peers connus de runs précédents (persistés sur disque, max 1 semaine).
/// Sert de seed pour démarrer rapidement avant que le premier beacon arrive.
/// Probés en HTTP par PeerSourceResolver — si le peer est offline on aura juste
/// un timeout puis on essaiera le suivant.
/// </summary>
public IReadOnlyList<DiscoveredPeer> ListKnown() => _knownPeers;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var cfg = _configProvider();
if (!cfg.ServerEnabled && !cfg.ClientEnabled)
{
_logger.LogInformation("LAN discovery disabled (both ServerEnabled and ClientEnabled are false)");
return;
}
_ourLanIp = GetLocalLanIp();
// Charge les peers connus de la session précédente. Les probes pourront
// ainsi essayer ces IPs immédiatement, sans attendre les 15s du premier
// beacon UDP. Si un peer est offline, on aura juste un timeout HTTP rapide.
_knownPeers = LoadKnownPeers();
if (_knownPeers.Count > 0)
_logger.LogInformation("Loaded {Count} known peers from disk cache", _knownPeers.Count);
// BOOTSTRAP ACTIF : kick off un probe HTTP /v1/info en parallèle sur tous les
// peers connus (cache disque) + manuels (config). Découverte instantanée au
// lieu d'attendre les 15 s du prochain beacon UDP. Fire-and-forget : le reste
// du service démarre normalement pendant que le probe tourne en background.
_ = Task.Run(() => BootstrapProbeKnownPeersAsync(cfg, stoppingToken), stoppingToken);
// Listener (toujours actif si Client OU Server activé)
UdpClient? listener = null;
try
{
listener = new UdpClient(AddressFamily.InterNetwork);
listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
listener.Client.Bind(new IPEndPoint(IPAddress.Any, cfg.DiscoveryPort));
listener.EnableBroadcast = true;
_logger.LogInformation("LAN discovery listening on UDP :{Port}", cfg.DiscoveryPort);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to bind UDP discovery socket on port {Port}", cfg.DiscoveryPort);
listener?.Dispose();
return;
}
var listenTask = ListenLoopAsync(listener, stoppingToken);
// Broadcaster (uniquement si Server activé)
Task? broadcastTask = null;
if (cfg.ServerEnabled)
{
broadcastTask = BroadcastLoopAsync(cfg.DiscoveryPort, cfg.ServerPort, stoppingToken);
}
try
{
await Task.WhenAll(broadcastTask is null ? new[] { listenTask } : new[] { listenTask, broadcastTask })
.ConfigureAwait(false);
}
catch (OperationCanceledException) { /* expected on shutdown */ }
finally
{
try { listener.Close(); } catch { }
listener.Dispose();
}
}
private async Task ListenLoopAsync(UdpClient listener, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
var result = await listener.ReceiveAsync(ct).ConfigureAwait(false);
var senderHost = result.RemoteEndPoint.Address.ToString();
// Ignore nos propres beacons
if (_ourLanIp is not null && string.Equals(senderHost, _ourLanIp, StringComparison.OrdinalIgnoreCase))
continue;
var json = Encoding.UTF8.GetString(result.Buffer);
var beacon = JsonSerializer.Deserialize<BeaconPayload>(json);
if (beacon is null || beacon.V != 1) continue;
if (string.IsNullOrEmpty(beacon.Host) || beacon.Port <= 0 || beacon.Port > 65535) continue;
var peer = new DiscoveredPeer(
Host: beacon.Host,
Port: beacon.Port,
Versions: (IReadOnlyList<string>?)beacon.Versions ?? Array.Empty<string>(),
LastSeenUtc: DateTime.UtcNow);
_peers[beacon.Host] = peer;
// Persist to disk au plus toutes les 30s pour ne pas spammer le SSD
// (un beacon arrive toutes les 15s par peer × N peers = beaucoup d'écritures).
if ((DateTime.UtcNow - _lastDiskFlush).TotalSeconds >= 30)
{
_lastDiskFlush = DateTime.UtcNow;
SaveKnownPeers(_peers.Values);
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogDebug(ex, "Discovery listen iteration failed (will retry)");
try { await Task.Delay(500, ct); } catch { break; }
}
}
}
private async Task BroadcastLoopAsync(int discoveryPort, int httpPort, CancellationToken ct)
{
using var sender = new UdpClient(AddressFamily.InterNetwork) { EnableBroadcast = true };
var endpoint = new IPEndPoint(IPAddress.Broadcast, discoveryPort);
while (!ct.IsCancellationRequested)
{
try
{
var versions = _cache.ListCached().Select(c => c.Version).ToList();
var host = _ourLanIp ?? GetLocalLanIp() ?? "0.0.0.0";
var payload = new BeaconPayload
{
V = 1,
Host = host,
Port = httpPort,
Versions = versions,
};
var json = JsonSerializer.Serialize(payload);
var bytes = Encoding.UTF8.GetBytes(json);
await sender.SendAsync(bytes, bytes.Length, endpoint).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Discovery broadcast failed (will retry)");
}
try { await Task.Delay(BeaconIntervalMs, ct); } catch { break; }
}
}
/// <summary>
/// Renvoie l'IPv4 LAN du PC : interface UP non-loopback avec une default
/// gateway IPv4. Filtre Hyper-V virtual switch, VPN sans gateway, etc.
/// Same logic that MainViewModel.LocalIpAddress uses for the sidebar info.
/// </summary>
public static string? GetLocalLanIp()
{
try
{
return NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up
&& n.NetworkInterfaceType != NetworkInterfaceType.Loopback
&& n.GetIPProperties().GatewayAddresses
.Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork))
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork)
.Select(a => a.Address.ToString())
.FirstOrDefault();
}
catch { return null; }
}
private sealed class BeaconPayload
{
[JsonPropertyName("v")] public int V { get; set; }
[JsonPropertyName("host")] public string Host { get; set; } = "";
[JsonPropertyName("port")] public int Port { get; set; }
[JsonPropertyName("versions")] public List<string>? Versions { get; set; }
}
private sealed class StoredPeer
{
[JsonPropertyName("host")] public string Host { get; set; } = "";
[JsonPropertyName("port")] public int Port { get; set; }
[JsonPropertyName("versions")] public List<string> Versions { get; set; } = new();
[JsonPropertyName("lastSeenUtc")] public DateTime LastSeenUtc { get; set; }
}
private string GetKnownPeersPath() =>
Path.Combine(_configStore.ConfigDirectory, KnownPeersFileName);
private List<DiscoveredPeer> LoadKnownPeers()
{
var path = GetKnownPeersPath();
if (!File.Exists(path)) return new List<DiscoveredPeer>();
try
{
var raw = File.ReadAllText(path);
var stored = JsonSerializer.Deserialize<List<StoredPeer>>(raw);
if (stored is null) return new List<DiscoveredPeer>();
var staleAfter = DateTime.UtcNow.AddHours(-KnownPeerStaleHours);
return stored
.Where(s => s.LastSeenUtc >= staleAfter
&& !string.IsNullOrEmpty(s.Host)
&& s.Port > 0 && s.Port < 65536)
.Select(s => new DiscoveredPeer(s.Host, s.Port, s.Versions, s.LastSeenUtc))
.ToList();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to load known peers cache (non-fatal)");
return new List<DiscoveredPeer>();
}
}
/// <summary>
/// Probe HTTP actif en parallèle de tous les peers connus (cache disque) et
/// manuels (config). Pour chaque peer qui répond à /v1/info, on l'injecte dans
/// _peers avec lastSeen=now → il apparaît immédiatement dans ListDiscovered()
/// et donc dans la sidebar + PeerSourceResolver. Aucune attente d'un beacon UDP.
/// Timeout court (1 s) par peer, parallel pour ne pas additionner.
/// </summary>
private async Task BootstrapProbeKnownPeersAsync(LanCacheConfig cfg, CancellationToken ct)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var urls = new List<string>();
foreach (var p in _knownPeers)
{
var u = $"http://{p.Host}:{p.Port}";
if (seen.Add(u)) urls.Add(u);
}
foreach (var raw in cfg.ManualPeerUrls ?? new List<string>())
{
var t = raw.Trim().TrimEnd('/');
if (string.IsNullOrEmpty(t)) continue;
if (seen.Add(t)) urls.Add(t);
}
if (urls.Count == 0)
{
_logger.LogDebug("Bootstrap probe: no known/manual peers to probe");
return;
}
_logger.LogInformation("Bootstrap probe: attempting {Count} known/manual peers in parallel", urls.Count);
var tasks = urls.Select(u => ProbeOnePeerAsync(u, ct)).ToArray();
await Task.WhenAll(tasks).ConfigureAwait(false);
var alive = _peers.Count;
_logger.LogInformation("Bootstrap probe done: {Alive} peer(s) responding", alive);
if (alive > 0)
{
// Persist immediately — la session précédente avait peut-être des peers stale,
// on remet à jour avec ceux qui répondent vraiment maintenant.
SaveKnownPeers(_peers.Values);
_lastDiskFlush = DateTime.UtcNow;
}
}
private async Task ProbeOnePeerAsync(string baseUrl, CancellationToken ct)
{
try
{
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(TimeSpan.FromSeconds(1));
using var resp = await _http.GetAsync($"{baseUrl}/v1/info", probeCts.Token).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode) return;
var json = await resp.Content.ReadAsStringAsync(probeCts.Token).ConfigureAwait(false);
var beacon = JsonSerializer.Deserialize<BeaconPayload>(json);
if (beacon is null || string.IsNullOrEmpty(beacon.Host) || beacon.Port <= 0) return;
// Ignore notre propre IP (au cas où une URL manuelle pointerait sur nous-mêmes)
if (_ourLanIp is not null && string.Equals(beacon.Host, _ourLanIp, StringComparison.OrdinalIgnoreCase))
return;
var peer = new DiscoveredPeer(
Host: beacon.Host,
Port: beacon.Port,
Versions: (IReadOnlyList<string>?)beacon.Versions ?? Array.Empty<string>(),
LastSeenUtc: DateTime.UtcNow);
_peers[beacon.Host] = peer;
_logger.LogInformation("Bootstrap probe: peer alive at {Url} (versions: {V})", baseUrl,
beacon.Versions is { Count: > 0 } ? string.Join(",", beacon.Versions) : "—");
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* shutdown */ }
catch (Exception ex)
{
_logger.LogDebug("Bootstrap probe of {Url} failed: {Reason}", baseUrl, ex.Message);
}
}
private void SaveKnownPeers(IEnumerable<DiscoveredPeer> peers)
{
var path = GetKnownPeersPath();
try
{
var stored = peers
.Where(p => !string.IsNullOrEmpty(p.Host))
.Select(p => new StoredPeer
{
Host = p.Host,
Port = p.Port,
Versions = p.Versions.ToList(),
LastSeenUtc = p.LastSeenUtc,
})
.ToList();
var json = JsonSerializer.Serialize(stored);
// Atomic write : .tmp puis rename pour ne pas corrompre si crash mi-écriture.
var tmp = path + ".tmp";
File.WriteAllText(tmp, json);
File.Move(tmp, path, overwrite: true);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to save known peers cache (non-fatal)");
}
}
}

View File

@@ -0,0 +1,75 @@
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.Lan;
public sealed class PeerManifestFetcher : IPeerManifestFetcher
{
private const int MaxPeersToProbe = 5;
private readonly Func<LanCacheConfig> _configProvider;
private readonly ILanDiscoveryService _discovery;
private readonly HttpClient _http;
private readonly ILogger<PeerManifestFetcher> _logger;
public PeerManifestFetcher(
Func<LanCacheConfig> configProvider,
ILanDiscoveryService discovery,
HttpClient http,
ILogger<PeerManifestFetcher> logger)
{
_configProvider = configProvider;
_discovery = discovery;
_http = http;
_logger = logger;
}
public async Task<string?> TryFetchRawAsync(CancellationToken ct)
{
var cfg = _configProvider();
if (!cfg.ClientEnabled) return null;
// Liste fusionnée : actifs + connus (cache disque) + manuels.
var candidates = PeerCandidates.Build(_discovery, cfg.ManualPeerUrls);
if (candidates.Count == 0)
{
_logger.LogDebug("No peers available for manifest fallback");
return null;
}
var probeMs = cfg.ProbeTimeoutMs > 0 ? cfg.ProbeTimeoutMs : 1000;
foreach (var baseUrl in candidates.Take(MaxPeersToProbe))
{
ct.ThrowIfCancellationRequested();
try
{
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(probeMs);
var url = $"{baseUrl}/v1/manifest";
using var resp = await _http.GetAsync(url, probeCts.Token).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
_logger.LogDebug("Peer {Url} manifest fetch returned {Status}", baseUrl, (int)resp.StatusCode);
continue;
}
var json = await resp.Content.ReadAsStringAsync(probeCts.Token).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json)) continue;
_logger.LogInformation("Manifest fetched from peer {Url} ({Bytes} bytes)", baseUrl, json.Length);
return json;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
_logger.LogDebug("Peer {Url} manifest probe timed out", baseUrl);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Peer {Url} manifest probe failed", baseUrl);
}
}
_logger.LogInformation("No peer responded with a manifest");
return null;
}
}

View File

@@ -0,0 +1,111 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.Lan;
public sealed class PeerSourceResolver : IPeerSourceResolver
{
private const int MaxPeersToProbe = 5;
private readonly Func<LanCacheConfig> _configProvider;
private readonly ILanDiscoveryService _discovery;
private readonly HttpClient _http;
private readonly ILogger<PeerSourceResolver> _logger;
public PeerSourceResolver(
Func<LanCacheConfig> configProvider,
ILanDiscoveryService discovery,
HttpClient http,
ILogger<PeerSourceResolver> logger)
{
_configProvider = configProvider;
_discovery = discovery;
_http = http;
_logger = logger;
}
public async Task<PeerSource?> TryResolveAsync(
string version,
long expectedSize,
string expectedSha256,
CancellationToken ct)
{
var cfg = _configProvider();
if (!cfg.ClientEnabled)
{
return null;
}
// Construit la liste fusionnée : actifs (beacons récents) + connus (cache
// disque) + manuels. PeerCandidates dédoublonne et préserve l'ordre.
var candidates = PeerCandidates.Build(_discovery, cfg.ManualPeerUrls);
if (candidates.Count == 0)
{
_logger.LogDebug("No peers configured/discovered, skipping LAN probe");
return null;
}
var probeMs = cfg.ProbeTimeoutMs > 0 ? cfg.ProbeTimeoutMs : 1000;
foreach (var baseUrl in candidates.Take(MaxPeersToProbe))
{
ct.ThrowIfCancellationRequested();
try
{
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(probeMs);
var hasUrl = $"{baseUrl}/v1/has/{version}";
using var resp = await _http.GetAsync(hasUrl, probeCts.Token).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
_logger.LogDebug("Peer {Url} returned {Status} for v{Version}", baseUrl, (int)resp.StatusCode, version);
continue;
}
var info = await resp.Content.ReadFromJsonAsync<HasResponse>(probeCts.Token).ConfigureAwait(false);
if (info is null) continue;
// Vérif size + sha256 contre le manifest signé Ed25519.
// Si un peer ment, son ZIP sera rejeté à la vérif SHA finale par
// DownloadManager — mais on peut filtrer dès maintenant pour ne
// pas perdre 14 Go de DL inutile.
if (info.Size != expectedSize)
{
_logger.LogWarning("Peer {Url} reports size {Size} for v{Version}, expected {Expected} — skipping",
baseUrl, info.Size, version, expectedSize);
continue;
}
if (!string.Equals(info.Sha256, expectedSha256, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Peer {Url} reports SHA mismatch for v{Version} — skipping", baseUrl, version);
continue;
}
var zipUrl = new Uri($"{baseUrl}/v1/zip/{version}");
var host = new Uri(baseUrl).Host;
_logger.LogInformation("Peer match for v{Version} : {Url}", version, baseUrl);
return new PeerSource(zipUrl, host);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
_logger.LogDebug("Peer {Url} probe timed out for v{Version}", baseUrl, version);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Peer {Url} probe failed for v{Version}", baseUrl, version);
}
}
_logger.LogInformation("No peer has v{Version}, will fall back to OVH", version);
return null;
}
private sealed class HasResponse
{
[JsonPropertyName("sha256")] public string Sha256 { get; set; } = "";
[JsonPropertyName("size")] public long Size { get; set; }
[JsonPropertyName("mtime")] public DateTime Mtime { get; set; }
}
}

View File

@@ -0,0 +1,212 @@
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations;
namespace PSLauncher.Core.Lan;
public sealed class ZipCacheStore : IZipCacheStore
{
// Validation stricte du nom de version pour éviter path traversal.
// Couvre semver (1.2.3) et variantes (1.2.3-rc1).
private static readonly Regex VersionRegex = new(@"^[0-9A-Za-z._-]+$", RegexOptions.Compiled);
private const string ZipPrefix = "proserve-";
private const string ZipSuffix = ".zip";
private const string ShaSuffix = ".zip.sha256";
private readonly IDownloadStateStore _downloadStore;
private readonly IInstallationRegistry _registry;
private readonly ILogger<ZipCacheStore> _logger;
public ZipCacheStore(
IDownloadStateStore downloadStore,
IInstallationRegistry registry,
ILogger<ZipCacheStore> logger)
{
_downloadStore = downloadStore;
_registry = registry;
_logger = logger;
}
public string GetCachedZipPath(string version)
{
if (!VersionRegex.IsMatch(version))
throw new ArgumentException($"Invalid version: {version}", nameof(version));
return Path.Combine(_downloadStore.GetDownloadsDirectory(), $"{ZipPrefix}{version}{ZipSuffix}");
}
private string GetShaPath(string version) =>
Path.Combine(_downloadStore.GetDownloadsDirectory(), $"{ZipPrefix}{version}{ShaSuffix}");
public bool TryGetCachedEntry(string version, out CachedZip entry)
{
entry = null!;
if (!VersionRegex.IsMatch(version)) return false;
var zipPath = GetCachedZipPath(version);
var shaPath = GetShaPath(version);
if (!File.Exists(zipPath) || !File.Exists(shaPath)) return false;
try
{
var sha = File.ReadAllText(shaPath, Encoding.ASCII).Trim();
if (sha.Length != 64) return false; // SHA-256 hex = 64 chars
var fi = new FileInfo(zipPath);
// Force la lecture des metadata maintenant — si le fichier disparaît
// ici (race avec File.Move overwrite=true du DownloadManager), on attrape
// FileNotFoundException ci-dessous silencieusement. Sans ce Refresh(),
// FileInfo.Length aurait fait l'I/O lazy et throw plus tard.
fi.Refresh();
entry = new CachedZip(version, zipPath, fi.Length, sha, fi.LastWriteTimeUtc);
return true;
}
catch (FileNotFoundException)
{
// Race connue et bénigne : un peer client probe /v1/has/{ver} pile au
// moment où DownloadManager.VerifyAndFinalizeAsync fait File.Move
// (delete-then-rename sur NTFS). Le client retentera ou tombera sur OVH.
return false;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Cache lookup failed for {Version}", version);
return false;
}
}
public async Task PromoteAsync(string srcPath, string version, string sha256, CancellationToken ct)
{
if (!VersionRegex.IsMatch(version))
throw new ArgumentException($"Invalid version: {version}", nameof(version));
if (sha256.Length != 64)
throw new ArgumentException("SHA-256 hex must be 64 chars", nameof(sha256));
var dstPath = GetCachedZipPath(version);
var shaPath = GetShaPath(version);
await Task.Run(() =>
{
ct.ThrowIfCancellationRequested();
// Si le ZIP est déjà au bon endroit (cas standard post-DownloadManager :
// VerifyAndFinalizeAsync a déjà fait File.Move(partial, final)), pas besoin
// de bouger. On écrit juste le sidecar SHA.
if (!string.Equals(Path.GetFullPath(srcPath), Path.GetFullPath(dstPath), StringComparison.OrdinalIgnoreCase))
{
if (File.Exists(dstPath)) File.Delete(dstPath);
File.Move(srcPath, dstPath);
}
File.WriteAllText(shaPath, sha256.ToLowerInvariant(), Encoding.ASCII);
}, ct).ConfigureAwait(false);
_logger.LogInformation("Promoted v{Version} to LAN cache (size={Size} bytes)", version, new FileInfo(dstPath).Length);
}
public async Task PruneAsync(int keepLatest, CancellationToken ct)
{
if (keepLatest < 0) keepLatest = 0;
await Task.Run(() =>
{
try
{
// Versions encore installées : à protéger inconditionnellement.
var installedVersions = new HashSet<string>(
_registry.Scan().Select(i => i.Version),
StringComparer.OrdinalIgnoreCase);
var all = ListCached()
.OrderByDescending(c => c.ModifiedUtc)
.ToList();
int kept = 0;
foreach (var entry in all)
{
ct.ThrowIfCancellationRequested();
bool isInstalled = installedVersions.Contains(entry.Version);
if (kept < keepLatest || isInstalled)
{
kept++;
continue;
}
try
{
File.Delete(entry.Path);
var shaPath = GetShaPath(entry.Version);
if (File.Exists(shaPath)) File.Delete(shaPath);
_logger.LogInformation("Pruned cached ZIP v{Version} ({Size} bytes)", entry.Version, entry.SizeBytes);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not prune cached ZIP {Path} (will retry next time)", entry.Path);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cache pruning failed (non-fatal)");
}
}, ct).ConfigureAwait(false);
}
public async Task InvalidateAsync(string version, CancellationToken ct)
{
if (!VersionRegex.IsMatch(version))
{
_logger.LogWarning("InvalidateAsync called with invalid version: {Version}", version);
return;
}
await Task.Run(() =>
{
ct.ThrowIfCancellationRequested();
var zipPath = GetCachedZipPath(version);
var shaPath = GetShaPath(version);
bool zipDeleted = false, shaDeleted = false;
try
{
if (File.Exists(zipPath)) { File.Delete(zipPath); zipDeleted = true; }
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not delete cached ZIP for v{Version} at {Path} (file lock?)",
version, zipPath);
}
try
{
if (File.Exists(shaPath)) { File.Delete(shaPath); shaDeleted = true; }
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not delete sidecar SHA for v{Version} at {Path}",
version, shaPath);
}
if (zipDeleted || shaDeleted)
{
_logger.LogInformation(
"Invalidated LAN cache for v{Version} (zip={ZipGone}, sidecar={ShaGone})",
version, zipDeleted, shaDeleted);
}
}, ct).ConfigureAwait(false);
}
public IReadOnlyList<CachedZip> ListCached()
{
var dir = _downloadStore.GetDownloadsDirectory();
if (!Directory.Exists(dir)) return Array.Empty<CachedZip>();
var result = new List<CachedZip>();
foreach (var zipPath in Directory.EnumerateFiles(dir, $"{ZipPrefix}*{ZipSuffix}", SearchOption.TopDirectoryOnly))
{
// Skip les .partial (ne matchent pas le pattern de toute façon)
var name = Path.GetFileName(zipPath);
if (!name.StartsWith(ZipPrefix, StringComparison.Ordinal)) continue;
if (!name.EndsWith(ZipSuffix, StringComparison.Ordinal)) continue;
var version = name.Substring(ZipPrefix.Length, name.Length - ZipPrefix.Length - ZipSuffix.Length);
if (!VersionRegex.IsMatch(version)) continue;
if (TryGetCachedEntry(version, out var entry))
result.Add(entry);
}
return result;
}
}

View File

@@ -16,8 +16,14 @@ public interface ILicenseService
/// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version.
/// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
/// <paramref name="expectedFilename"/> est le nom du fichier attendu, extrait du
/// <c>download.url</c> côté manifest client. Sert au serveur à disambiguer les
/// entrées quand plusieurs channels partagent le même numéro de version
/// (proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32 sur v1.5.4.32).
/// Si null ou vide, le serveur retombe sur "1re entrée matchant le numéro"
/// (rétro-compat avec les vieux serveurs qui ignorent ce paramètre).
/// </summary>
Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct);
Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct);
/// <summary>
/// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null

View File

@@ -7,6 +7,7 @@ using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using NSec.Cryptography;
using PSLauncher.Core.Configuration;
using PSLauncher.Core.Security;
using PSLauncher.Models;
namespace PSLauncher.Core.Licensing;
@@ -36,6 +37,7 @@ public sealed class LicenseService : ILicenseService
private readonly Func<string> _serverBaseUrlProvider;
private readonly IConfigStore _configStore;
private readonly LocalConfig _config;
private readonly ISettingsLockService _settingsLock;
private readonly ILogger<LicenseService> _logger;
private readonly string? _serverPublicKeyHex;
@@ -44,10 +46,12 @@ public sealed class LicenseService : ILicenseService
Func<string> serverBaseUrlProvider,
IConfigStore configStore,
LocalConfig config,
ISettingsLockService settingsLock,
ILogger<LicenseService> logger)
{
_http = http;
_serverBaseUrlProvider = serverBaseUrlProvider;
_settingsLock = settingsLock;
_configStore = configStore;
_config = config;
_logger = logger;
@@ -87,11 +91,19 @@ public sealed class LicenseService : ILicenseService
_logger.LogInformation("Validating license at {Url} (key {KeyHint}…)", url, licenseKey.Length >= 8 ? licenseKey[..8] : licenseKey);
// Timeout court (3 s) pour fail-fast en offline. Le HttpClient global a
// Timeout=Infinite, donc sans ce CTS on hangerait jusqu'à ce que l'OS
// abandonne le SYN TCP (~15-21 s sur Windows). Le call site (Refresh
// License pendant Check Updates) catch l'exception et garde le cache
// local, donc fail-fast est sans danger.
using var validateCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
validateCts.CancelAfter(3_000);
using var httpReq = new HttpRequestMessage(HttpMethod.Post, url);
httpReq.Content = JsonContent.Create(req);
using var resp = await _http.SendAsync(httpReq, ct).ConfigureAwait(false);
using var resp = await _http.SendAsync(httpReq, validateCts.Token).ConfigureAwait(false);
var bodyText = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
var bodyText = await resp.Content.ReadAsStringAsync(validateCts.Token).ConfigureAwait(false);
LicenseValidationResponse? parsed;
try
{
@@ -122,6 +134,10 @@ public sealed class LicenseService : ILicenseService
_logger.LogDebug("License response signature OK");
}
// Propage le hash du mot de passe settings lock vers le service in-memory.
// L'intégrité est garantie par la signature Ed25519 vérifiée juste au-dessus.
_settingsLock.SetPasswordHash(parsed.SettingsLockPasswordHash);
return parsed;
}
@@ -198,13 +214,26 @@ public sealed class LicenseService : ILicenseService
cachedStatus = "expired";
}
return new LicenseValidationResponse
var cached = new LicenseValidationResponse
{
Status = cachedStatus,
OwnerName = _config.License.CachedOwnerName,
DownloadEntitlementUntil = _config.License.CachedEntitlementUntil,
ServerTime = lastValidation,
// Restauration des champs channel + canSeeBetas (ajoutés persistants
// depuis v0.27.2). Sans ça, le channelProvider du ManifestService
// retombait sur null à chaque relance, le client fetch sans
// ?channel=, et l'utilisateur ne voyait que les versions default —
// jamais les builds spécifiques à son channel (firefighter, police…).
Channel = _config.License.CachedChannel,
CanSeeBetas = _config.License.CachedCanSeeBetas,
SettingsLockPasswordHash = _config.License.CachedSettingsLockPasswordHash,
};
// Propagation au service in-memory : même au démarrage offline, le hash
// cache permet de gater les Settings → Avancés tant qu'on n'a pas pu
// revalider online.
_settingsLock.SetPasswordHash(cached.SettingsLockPasswordHash);
return cached;
}
public void SaveCached(string licenseKey, LicenseValidationResponse response)
@@ -219,6 +248,9 @@ public sealed class LicenseService : ILicenseService
_config.License.CachedOwnerName = response.OwnerName;
_config.License.CachedEntitlementUntil = response.DownloadEntitlementUntil;
_config.License.CachedStatus = response.Status;
_config.License.CachedChannel = response.Channel;
_config.License.CachedCanSeeBetas = response.CanSeeBetas;
_config.License.CachedSettingsLockPasswordHash = response.SettingsLockPasswordHash;
_configStore.Save(_config);
}
@@ -234,7 +266,7 @@ public sealed class LicenseService : ILicenseService
return license.CanDownload(version);
}
public async Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct)
public async Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct)
{
var key = GetDecryptedKey();
if (string.IsNullOrEmpty(key)) return null;
@@ -244,22 +276,36 @@ public sealed class LicenseService : ILicenseService
// PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
var encodedKey = Uri.EscapeDataString(key);
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
// Passe le filename attendu pour disambiguer les channels sur les manifests
// multi-entrée par version. Si null/empty, le serveur retombe sur "1re entrée
// matchant le numéro" (rétro-compat avec les vieux serveurs).
if (!string.IsNullOrWhiteSpace(expectedFilename))
{
url += "&filename=" + Uri.EscapeDataString(expectedFilename);
}
try
{
// Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas
// en 5s, on fallback sur l'URL publique du manifest plutôt que de bloquer
// la UI 100s. Le DL via peer LAN a déjà été tenté avant ce point — si on
// est ici c'est qu'aucun peer n'avait la version.
using var sigCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
sigCts.CancelAfter(5_000);
using var req = new HttpRequestMessage(HttpMethod.Get, url);
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
using var resp = await _http.SendAsync(req, sigCts.Token).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
_logger.LogInformation("Signed URL endpoint returned {Status} — falling back to public URL", (int)resp.StatusCode);
return null;
}
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
var body = await resp.Content.ReadAsStringAsync(sigCts.Token).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
return doc.RootElement.TryGetProperty("url", out var u) ? u.GetString() : null;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex, "Signed URL fetch failed, falling back to public URL");
_logger.LogWarning("Signed URL fetch failed/timeout ({Reason}), falling back to public URL", ex.Message);
return null;
}
}
@@ -295,7 +341,11 @@ public sealed class LicenseService : ILicenseService
/// </summary>
public static byte[] CanonicalBytesFor(LicenseValidationResponse response)
{
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature.
// ATTENTION : ordre CRITIQUE — Crypto::canonicalJson côté PHP ne trie pas
// les clés, il prend l'ordre du tableau associatif. Toute modif ici doit
// être miroir exact du dictionnaire PHP dans ValidateLicense.php.
// Version courante (v3, depuis v0.28) : inclut settingsLockPasswordHash.
var dict = new Dictionary<string, object?>
{
["status"] = response.Status,
@@ -304,6 +354,37 @@ public sealed class LicenseService : ILicenseService
["issuedAt"] = FormatDateAtom(response.IssuedAt),
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
["maxMachines"] = response.MaxMachines,
["channel"] = response.Channel, // null si default
["canSeeBetas"] = response.CanSeeBetas,
["settingsLockPasswordHash"] = response.SettingsLockPasswordHash, // null si pas de lock
["serverTime"] = FormatDateAtom(response.ServerTime),
};
var opts = new JsonSerializerOptions
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
}
/// <summary>
/// Canonical v2 (v0.26 → v0.27) : sans <c>settingsLockPasswordHash</c>.
/// Conservé pour valider les réponses cachées par les anciennes versions
/// du client/serveur. Une fois que toutes les licenses auront été re-validées
/// online avec le nouveau serveur, on pourra retirer cette méthode.
/// </summary>
private static byte[] CanonicalBytesForV2(LicenseValidationResponse response)
{
var dict = new Dictionary<string, object?>
{
["status"] = response.Status,
["licenseId"] = response.LicenseId,
["ownerName"] = response.OwnerName,
["issuedAt"] = FormatDateAtom(response.IssuedAt),
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
["maxMachines"] = response.MaxMachines,
["channel"] = response.Channel,
["canSeeBetas"] = response.CanSeeBetas,
["serverTime"] = FormatDateAtom(response.ServerTime),
};
var opts = new JsonSerializerOptions
@@ -332,8 +413,22 @@ public sealed class LicenseService : ILicenseService
var pkBytes = Convert.FromHexString(publicKeyHex);
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
var sig = Convert.FromBase64String(base64Signature);
var payload = CanonicalBytesFor(response);
return alg.Verify(pk, payload, sig);
// Chaîne de fallback canonical pour compat ascendante :
// v3 (current) : avec settingsLockPasswordHash (depuis v0.28)
// v2 : avec channel + canSeeBetas (v0.26 → v0.27)
// legacy : sans aucun de ces champs (avant v0.26)
// Sans ces fallbacks, les licenses cachées par d'anciennes versions
// deviendraient invalides et l'utilisateur serait forcé de revalider
// online — bloqué hors-ligne.
var payloadV3 = CanonicalBytesFor(response);
if (alg.Verify(pk, payloadV3, sig)) return true;
var payloadV2 = CanonicalBytesForV2(response);
if (alg.Verify(pk, payloadV2, sig)) return true;
var payloadLegacy = CanonicalBytesForLegacy(response);
return alg.Verify(pk, payloadLegacy, sig);
}
catch
{
@@ -341,6 +436,33 @@ public sealed class LicenseService : ILicenseService
}
}
/// <summary>
/// Canonical historique (avant v0.26.0) : pas de <c>channel</c> ni
/// <c>canSeeBetas</c>. Conservé pour valider les réponses cachées par
/// les anciennes versions du client. Une fois que toutes les licenses
/// auront été re-validées online avec le nouveau serveur, on pourra
/// retirer cette méthode (mais on prévient pas de coût à la garder).
/// </summary>
private static byte[] CanonicalBytesForLegacy(LicenseValidationResponse response)
{
var dict = new Dictionary<string, object?>
{
["status"] = response.Status,
["licenseId"] = response.LicenseId,
["ownerName"] = response.OwnerName,
["issuedAt"] = FormatDateAtom(response.IssuedAt),
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
["maxMachines"] = response.MaxMachines,
["serverTime"] = FormatDateAtom(response.ServerTime),
};
var opts = new JsonSerializerOptions
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
}
// ----- Public key embarquée -----
private static string? TryReadEmbeddedPublicKey()

View File

@@ -8,7 +8,11 @@ namespace PSLauncher.Core.Localization;
/// session. Tout changement de langue nécessite un redémarrage du launcher
/// (les bindings XAML x:Static ne refresh pas dynamiquement).
///
/// Langues supportées : fr (par défaut), en, zh (Simplified), th, ar.
/// Langues supportées :
/// • fr (par défaut), en, zh (Simplified), th, ar : traduction complète historique
/// • es, de : ajoutées en v0.29.11, traduction progressive — les strings non encore
/// traduites tombent automatiquement sur l'anglais via T() (cf. fallback dans
/// <see cref="T(string,string,string,string,string,string?,string?)"/>).
/// Pour Arabic, FlowDirection doit aussi être basculé en RTL côté Window.
/// </summary>
public static class Strings
@@ -27,6 +31,8 @@ public static class Strings
("auto", "Automatique (langue du système)"),
("fr", "Français"),
("en", "English"),
("es", "Español"),
("de", "Deutsch"),
("zh", "中文"),
("th", "ไทย"),
("ar", "العربية"),
@@ -39,7 +45,7 @@ public static class Strings
/// </summary>
public static void Init(string? configValue)
{
var supported = new[] { "fr", "en", "zh", "th", "ar" };
var supported = new[] { "fr", "en", "es", "de", "zh", "th", "ar" };
var v = configValue?.Trim().ToLowerInvariant() ?? "auto";
if (v == "auto" || string.IsNullOrEmpty(v) || Array.IndexOf(supported, v) < 0)
@@ -62,9 +68,21 @@ public static class Strings
catch { /* culture inconnue, on garde le défaut */ }
}
private static string T(string fr, string en, string zh, string th, string ar) => _lang switch
/// <summary>
/// Helper de localisation. Les 5 premiers paramètres (fr/en/zh/th/ar) sont
/// requis = chaque string DOIT exister dans ces langues (traduction historique
/// complète). Les 2 derniers (es, de) sont OPTIONNELS — si non fournis, on
/// retombe sur l'anglais. Stratégie progressive : on traduit en es/de
/// uniquement les strings les plus visibles (top bar, actions principales,
/// états licenses, errors common) ; le reste reste en anglais pour les
/// utilisateurs es/de tant qu'on n'a pas traduit. Mieux qu'un crash ou
/// une chaîne vide, et l'anglais est globalement lisible par ces locuteurs.
/// </summary>
private static string T(string fr, string en, string zh, string th, string ar, string? es = null, string? de = null) => _lang switch
{
"en" => en,
"es" => es ?? en, // fallback English si pas encore traduit
"de" => de ?? en, // idem
"zh" => zh,
"th" => th,
"ar" => ar,
@@ -72,39 +90,67 @@ public static class Strings
};
// ==================== TOP BAR ====================
public static string TopBarCheckUpdates => T("🔄 Vérifier les MAJ", "🔄 Check for updates", "🔄 检查更新", "🔄 ตรวจสอบการอัปเดต", "🔄 التحقق من التحديثات");
public static string TopBarSettings => T("⚙ Paramètres", "⚙ Settings", "⚙ 设置", "⚙ ตั้งค่า", "⚙ الإعدادات");
public static string TopBarCheckUpdates => T("🔄 Vérifier les MAJ", "🔄 Check for updates", "🔄 检查更新", "🔄 ตรวจสอบการอัปเดต", "🔄 التحقق من التحديثات", "🔄 Buscar actualizaciones", "🔄 Updates suchen");
public static string TopBarSettings => T("⚙ Paramètres", "⚙ Settings", "⚙ 设置", "⚙ ตั้งค่า", "⚙ الإعدادات", "⚙ Ajustes", "⚙ Einstellungen");
// ==================== BODY ====================
public static string BodyVersions => T("Versions", "Versions", "版本", "เวอร์ชัน", "النسخ");
public static string BodyVersions => T("Versions", "Versions", "版本", "เวอร์ชัน", "النسخ", "Versiones", "Versionen");
public static string BodyVersionsHint => T(
"Lance, installe ou supprime une version. Les versions installées et celles disponibles sur le serveur cohabitent.",
"Launch, install or remove a version. Installed and available versions can coexist.",
"启动、安装或删除版本。已安装的版本与服务器上可用的版本可以共存。",
"เปิด ติดตั้ง หรือลบเวอร์ชัน เวอร์ชันที่ติดตั้งและที่มีในเซิร์ฟเวอร์อยู่ร่วมกันได้",
"تشغيل أو تثبيت أو إزالة نسخة. النسخ المثبتة والمتاحة على الخادم يمكن أن تتعايش."
"تشغيل أو تثبيت أو إزالة نسخة. النسخ المثبتة والمتاحة على الخادم يمكن أن تتعايش.",
"Inicia, instala o elimina una versión. Las versiones instaladas y las disponibles en el servidor pueden coexistir.",
"Starte, installiere oder entferne eine Version. Installierte und verfügbare Versionen können koexistieren."
);
public static string FeaturedCurrent => T("VERSION COURANTE", "CURRENT VERSION", "当前版本", "เวอร์ชันปัจจุบัน", "النسخة الحالية");
public static string SectionOther => T("AUTRES VERSIONS", "OTHER VERSIONS", "其他版本", "เวอร์ชันอื่นๆ", "نسخ أخرى");
public static string ReleasedOn => T("Sortie le ", "Released ", "发布于 ", "เผยแพร่เมื่อ ", "صدر في ");
public static string FeaturedCurrent => T("VERSION COURANTE", "CURRENT VERSION", "当前版本", "เวอร์ชันปัจจุบัน", "النسخة الحالية", "VERSIÓN ACTUAL", "AKTUELLE VERSION");
public static string SectionOther => T("AUTRES VERSIONS", "OTHER VERSIONS", "其他版本", "เวอร์ชันอื่นๆ", "نسخ أخرى", "OTRAS VERSIONES", "ANDERE VERSIONEN");
public static string ReleasedOn => T("Sortie le ", "Released ", "发布于 ", "เผยแพร่เมื่อ ", "صدر في ", "Publicado el ", "Veröffentlicht am ");
// ==================== STATUS BADGES ====================
public static string StatusInstalled => T("● Installée", "● Installed", "● 已安装", "● ติดตั้งแล้ว", "● مثبتة");
public static string StatusAvailable => T("○ Disponible", "○ Available", "○ 可用", "○ พร้อมใช้งาน", "○ متاحة");
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…");
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…");
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
public static string StatusInstalled => T("● Installée", "● Installed", "● 已安装", "● ติดตั้งแล้ว", "● مثبتة", "● Instalada", "● Installiert");
public static string StatusAvailable => T("○ Disponible", "○ Available", "○ 可用", "○ พร้อมใช้งาน", "○ متاحة", "○ Disponible", "○ Verfügbar");
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…", "⬇ Descargando…", "⬇ Wird heruntergeladen…");
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…", "📦 Instalando…", "📦 Wird installiert…");
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…", "🗑 Eliminando…", "🗑 Wird entfernt…");
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…", "🔍 Verificando…", "🔍 Wird überprüft…");
// ==================== BETA BADGE ====================
/// <summary>Texte affiché dans la pill orange à côté de la version. Court et localisé.</summary>
public static string BetaBadgeLabel => T("BÊTA", "BETA", "测试版", "เบต้า", "تجريبي", "BETA", "BETA");
/// <summary>Tooltip par défaut quand la version est tag BÊTA mais sans note des testeurs.</summary>
public static string BetaBadgeTooltipDefault => T(
"Version BÊTA — utilisée pour les tests internes. Vos retours nous aident à la valider avant publication officielle.",
"BETA version — used for internal testing. Your feedback helps us validate it before official release.",
"测试版 — 用于内部测试。您的反馈有助于我们在正式发布前验证此版本。",
"เวอร์ชันเบต้า — ใช้สำหรับการทดสอบภายใน ความคิดเห็นของคุณช่วยให้เรายืนยันก่อนการเปิดตัวอย่างเป็นทางการ",
"إصدار تجريبي — يُستخدم للاختبار الداخلي. ملاحظاتك تساعدنا في التحقق منه قبل الإصدار الرسمي."
);
/// <summary>Tooltip avec les notes spécifiques fournies par l'admin côté serveur.</summary>
public static string BetaBadgeTooltip(string testerNotes) => T(
$"Version BÊTA — {testerNotes}",
$"BETA version — {testerNotes}",
$"测试版 — {testerNotes}",
$"เวอร์ชันเบต้า — {testerNotes}",
$"إصدار تجريبي — {testerNotes}"
);
// ==================== ACTIONS ====================
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل");
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء");
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً");
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ");
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق");
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل", "▶ Iniciar", "▶ Starten");
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل", "▶ INICIAR", "▶ STARTEN");
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت", "⬇ Instalar", "⬇ Installieren");
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت", "⬇ INSTALAR", "⬇ INSTALLIEREN");
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء", "Cancelar", "Abbrechen");
public static string ActionOk => T("OK", "OK", "确定", "ตกลง", "موافق", "OK", "OK");
public static string ActionYes => T("Oui", "Yes", "", "ใช่", "نعم", "Sí", "Ja");
public static string ActionNo => T("Non", "No", "否", "ไม่", "لا", "No", "Nein");
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً", "Más tarde", "Später");
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ", "Guardar", "Speichern");
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق", "Cerrar", "Schließen");
// Affiché sur le bouton install d'une version dont le minLicenseDate dépasse l'entitlement de la license.
// Concrètement : ta license expire le 2024-12-31 mais cette version a été sortie le 2025-03-15 → tu ne peux pas la télécharger.
// Tu peux toujours installer/lancer une version antérieure couverte par ta période de license.
@@ -138,6 +184,72 @@ public static class Strings
public static string SettingsInstallation => T("INSTALLATION", "INSTALLATION", "安装", "การติดตั้ง", "التثبيت");
public static string SettingsCache => T("CACHE DE TÉLÉCHARGEMENTS", "DOWNLOAD CACHE", "下载缓存", "แคชดาวน์โหลด", "ذاكرة التخزين المؤقت للتنزيلات");
public static string SettingsLogs => T("LOGS & APPLICATION", "LOGS & APP", "日志与应用", "บันทึกและแอป", "السجلات والتطبيق");
public static string SettingsDatabase => T("BASE DE DONNÉES (XAMPP / MySQL)", "DATABASE (XAMPP / MySQL)", "数据库XAMPP / MySQL", "ฐานข้อมูล (XAMPP / MySQL)", "قاعدة البيانات (XAMPP / MySQL)");
public static string SettingsDbHost => T("Hôte", "Host", "主机", "โฮสต์", "المضيف");
public static string SettingsDbPort => T("Port", "Port", "端口", "พอร์ต", "المنفذ");
public static string SettingsDbUser => T("Utilisateur", "User", "用户", "ผู้ใช้", "المستخدم");
public static string SettingsDbPassword => T("Mot de passe", "Password", "密码", "รหัสผ่าน", "كلمة المرور");
public static string SettingsDbName => T("Nom de la base", "Database name", "数据库名称", "ชื่อฐานข้อมูล", "اسم قاعدة البيانات");
public static string SettingsDbAutoMigrate => T(
"Appliquer automatiquement les migrations à l'install d'une nouvelle version",
"Automatically apply migrations when installing a new version",
"安装新版本时自动应用迁移",
"ปรับใช้การโยกย้ายโดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่",
"تطبيق الترقيات تلقائياً عند تثبيت نسخة جديدة"
);
public static string SettingsDbReplay => T("🔁 Rejouer les migrations", "🔁 Replay migrations", "🔁 重新执行迁移", "🔁 เรียกใช้การโยกย้ายอีกครั้ง", "🔁 إعادة تشغيل الترقيات");
// ==================== RIGHT NAV SIDEBAR ====================
public static string NavLibrary => T("Bibliothèque", "Library", "库", "ห้องสมุด", "المكتبة");
public static string NavReport => T("Statistiques", "Reports", "统计", "รายงาน", "التقارير");
public static string NavDocumentation => T("Documentation", "Documentation","文档", "เอกสาร", "التوثيق");
public static string SettingsReportUrl => T("URL de l'outil de reporting local", "Local reporting tool URL", "本地报告工具 URL", "URL ของเครื่องมือรายงานในเครื่อง", "عنوان أداة التقارير المحلية");
public static string SettingsDocsUrl => T("URL de la documentation", "Documentation URL", "文档 URL", "URL ของเอกสาร", "عنوان التوثيق");
public static string DocsPlaceholder => T(
"Aucune URL de documentation configurée.\n\nVa dans Paramètres → Avancés → Base de données pour pointer vers la documentation locale ou en ligne.",
"No documentation URL configured.\n\nGo to Settings → Advanced → Database to point to local or online documentation.",
"未配置文档 URL。\n\n前往 设置 → 高级 → 数据库 以指向本地或在线文档。",
"ยังไม่ได้กำหนด URL เอกสาร\n\nไปที่ การตั้งค่า → ขั้นสูง → ฐานข้อมูล เพื่อชี้ไปยังเอกสารในเครื่องหรือออนไลน์",
"لم يتم تكوين عنوان التوثيق.\n\nاذهب إلى الإعدادات → متقدم → قاعدة البيانات للإشارة إلى التوثيق المحلي أو عبر الإنترنت."
);
public static string WebViewLoadError(string url, string detail) => T(
$"Impossible de charger {url}\n\n{detail}\n\nVérifie que XAMPP est démarré et que l'URL est correcte (Paramètres → Avancés).",
$"Could not load {url}\n\n{detail}\n\nCheck that XAMPP is running and the URL is correct (Settings → Advanced).",
$"无法加载 {url}\n\n{detail}\n\n请检查 XAMPP 是否正在运行以及 URL 是否正确(设置 → 高级)。",
$"ไม่สามารถโหลด {url}\n\n{detail}\n\nตรวจสอบว่า XAMPP กำลังทำงานและ URL ถูกต้อง (ตั้งค่า → ขั้นสูง)",
$"تعذر تحميل {url}\n\n{detail}\n\nتأكد من تشغيل XAMPP وأن العنوان صحيح (الإعدادات → متقدم)."
);
public static string SettingsParallelSegments => T(
"Connexions parallèles pour les téléchargements (1-32, défaut 16)",
"Parallel connections for downloads (1-32, default 16)",
"下载的并行连接数 (1-32默认 16)",
"การเชื่อมต่อแบบขนานสำหรับการดาวน์โหลด (1-32, ค่าเริ่มต้น 16)",
"اتصالات متوازية للتنزيلات (1-32، الافتراضي 16)"
);
public static string SettingsParallelSegmentsHint => T(
"Plus de connexions = débit pic plus élevé et queue de fin de DL plus courte. Au-delà de 16 le serveur peut throttler.",
"More connections = higher peak throughput and shorter end-of-DL tail. Above 16 the server may throttle.",
"更多连接 = 更高的峰值吞吐量和更短的下载尾部。超过 16 服务器可能会限速。",
"การเชื่อมต่อมากขึ้น = ปริมาณงานสูงสุดมากขึ้นและส่วนท้ายของการดาวน์โหลดสั้นลง สูงกว่า 16 เซิร์ฟเวอร์อาจจำกัด",
"اتصالات أكثر = إنتاجية ذروة أعلى وذيل تنزيل أقصر. فوق 16 قد يقيّد الخادم."
);
public static string SettingsAdvanced => T(
"▸ PARAMÈTRES AVANCÉS (serveur, installation, base de données, logs)",
"▸ ADVANCED SETTINGS (server, installation, database, logs)",
"▸ 高级设置(服务器、安装、数据库、日志)",
"▸ ตั้งค่าขั้นสูง (เซิร์ฟเวอร์ การติดตั้ง ฐานข้อมูล บันทึก)",
"▸ الإعدادات المتقدمة (الخادم، التثبيت، قاعدة البيانات، السجلات)"
);
public static string SettingsAbout => T("À PROPOS", "ABOUT", "关于", "เกี่ยวกับ", "حول");
public static string SettingsDbHint => T(
"Le launcher applique automatiquement les scripts SQL bundled dans _migrations/ de chaque ZIP PROSERVE. Mot de passe stocké chiffré DPAPI.",
"The launcher automatically applies SQL scripts bundled in each PROSERVE ZIP's _migrations/ folder. Password stored DPAPI-encrypted.",
"启动器自动应用每个 PROSERVE ZIP 的 _migrations/ 文件夹中捆绑的 SQL 脚本。密码使用 DPAPI 加密存储。",
"Launcher จะปรับใช้สคริปต์ SQL ที่บรรจุใน _migrations/ ของ ZIP PROSERVE แต่ละชุดโดยอัตโนมัติ รหัสผ่านถูกเก็บแบบเข้ารหัส DPAPI",
"يطبق المُشغِّل تلقائياً نصوص SQL المرفقة في _migrations/ لكل ZIP من PROSERVE. كلمة المرور مُخزَّنة بتشفير DPAPI."
);
public static string SettingsLanguage => T("LANGUE", "LANGUAGE", "语言", "ภาษา", "اللغة");
public static string SettingsLanguageHint => T(
"Le launcher redémarrera pour appliquer le changement.",
@@ -166,6 +278,24 @@ public static class Strings
// ==================== UPDATE ====================
public static string UpdateAvailableTitle => T("Mise à jour disponible", "Update available", "有可用更新", "มีการอัปเดต", "تحديث متاح");
public static string UpdateDownload => T("⬇ Télécharger", "⬇ Download", "⬇ 下载", "⬇ ดาวน์โหลด", "⬇ تنزيل");
public static string UpdatePreserveSaveGames => T(
"Conserver les sauvegardes et replays de la version précédente",
"Keep save games and replays from the previous version",
"保留上一版本的存档和录像",
"เก็บไฟล์เซฟและรีเพลย์จากเวอร์ชันก่อนหน้า",
"الاحتفاظ بحفظات اللعبة والإعادات من النسخة السابقة",
"Conservar las partidas guardadas y repeticiones de la versión anterior",
"Spielstände und Replays der vorherigen Version beibehalten"
);
public static string UpdatePreserveSaveGamesTooltip => T(
"Copie les fichiers .sav (PROSERVE_UE_*/Saved/SaveGames) ET les replays .replay (PROSERVE_UE_*/Saved/Demos) de la version la plus récente déjà installée vers la nouvelle installation, après extraction. Si aucune version précédente n'est trouvée, l'option est sans effet.",
"Copies .sav files (PROSERVE_UE_*/Saved/SaveGames) AND .replay files (PROSERVE_UE_*/Saved/Demos) from the most recent already-installed version into the new install, after extraction. If no previous version is found, the option has no effect.",
"在解压后将最近已安装版本的 .sav 存档 (PROSERVE_UE_*/Saved/SaveGames) 和 .replay 录像 (PROSERVE_UE_*/Saved/Demos) 复制到新安装中。如果未找到之前的版本,则此选项无效。",
"คัดลอกไฟล์ .sav (PROSERVE_UE_*/Saved/SaveGames) และไฟล์ .replay (PROSERVE_UE_*/Saved/Demos) จากเวอร์ชันที่ติดตั้งล่าสุดไปยังการติดตั้งใหม่ หลังจากการแตกไฟล์ หากไม่พบเวอร์ชันก่อนหน้า ตัวเลือกนี้จะไม่มีผล",
"ينسخ ملفات .sav (PROSERVE_UE_*/Saved/SaveGames) وملفات .replay (PROSERVE_UE_*/Saved/Demos) من أحدث نسخة مثبتة بالفعل إلى التثبيت الجديد، بعد الاستخراج. إذا لم يتم العثور على نسخة سابقة، فلن يكون لهذا الخيار أي تأثير.",
"Copia los archivos .sav (PROSERVE_UE_*/Saved/SaveGames) y los archivos .replay (PROSERVE_UE_*/Saved/Demos) de la versión más reciente ya instalada en la nueva instalación, tras la extracción. Si no se encuentra una versión anterior, la opción no surte efecto.",
"Kopiert die .sav-Dateien (PROSERVE_UE_*/Saved/SaveGames) UND .replay-Dateien (PROSERVE_UE_*/Saved/Demos) der zuletzt installierten Version nach dem Entpacken in die neue Installation. Wird keine vorherige Version gefunden, hat die Option keine Wirkung."
);
public static string LauncherUpdateTitle => T("Mise à jour du launcher", "Launcher update", "启动器更新", "อัปเดต Launcher", "تحديث المُشغِّل");
public static string LauncherUpdateAvailable => T("Mise à jour du launcher disponible", "Launcher update available", "启动器更新可用", "มีการอัปเดต Launcher", "تحديث المُشغِّل متاح");
public static string LauncherUpdateBody => T(
@@ -178,15 +308,25 @@ public static class Strings
public static string LauncherUpdateNow => T("⬇ Mettre à jour", "⬇ Update now", "⬇ 立即更新", "⬇ อัปเดตเดี๋ยวนี้", "⬇ تحديث الآن");
// ==================== MESSAGEBOX TITLES ====================
public static string MsgBoxError => T("Erreur", "Error", "错误", "ข้อผิดพลาด", "خطأ");
public static string MsgBoxLaunchError => T("Erreur de lancement", "Launch error", "启动错误", "เปิดใช้งานล้มเหลว", "خطأ في التشغيل");
public static string MsgBoxConfirm => T("Confirmer", "Confirm", "确认", "ยืนยัน", "تأكيد");
public static string MsgBoxInfo => T("Information", "Information", "信息", "ข้อมูล", "معلومة");
public static string MsgBoxPatience => T("Patience", "Please wait", "请稍候", "โปรดรอ", "يرجى الانتظار");
public static string MsgBoxLanguageChange => T("Changement de langue", "Language change", "更改语言", "เปลี่ยนภาษา", "تغيير اللغة");
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار");
public static string MsgBoxError => T("Erreur", "Error", "错误", "ข้อผิดพลาด", "خطأ", "Error", "Fehler");
public static string MsgBoxLaunchError => T("Erreur de lancement", "Launch error", "启动错误", "เปิดใช้งานล้มเหลว", "خطأ في التشغيل", "Error al iniciar", "Startfehler");
public static string MsgBoxConfirm => T("Confirmer", "Confirm", "确认", "ยืนยัน", "تأكيد", "Confirmar", "Bestätigen");
public static string MsgBoxInfo => T("Information", "Information", "信息", "ข้อมูล", "معلومة", "Información", "Information");
public static string MsgBoxPatience => T("Patience", "Please wait", "请稍候", "โปรดรอ", "يرجى الانتظار", "Por favor espera", "Bitte warten");
public static string MsgBoxLanguageChange => T("Changement de langue", "Language change", "更改语言", "เปลี่ยนภาษา", "تغيير اللغة", "Cambio de idioma", "Sprachänderung");
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار", "Notas de versión", "Versionshinweise");
// ==================== MESSAGEBOX MESSAGES ====================
public static string MsgInstallCollision(string version, string folderName) => T(
$"Impossible d'installer v{version} : le dossier « {folderName} » est déjà occupé par une autre édition (channel différent) de cette version.\n\nPour éviter d'écraser silencieusement l'install existante, configure un « installFolderTemplate » distinct côté backoffice pour cette entrée (ex : « PROSERVE-firefighter v{{version}} »), puis relance l'install.",
$"Cannot install v{version}: the folder \"{folderName}\" is already used by another edition (different channel) of this version.\n\nTo avoid silently overwriting the existing install, configure a distinct \"installFolderTemplate\" for this entry in the backoffice (e.g. \"PROSERVE-firefighter v{{version}}\"), then retry the install.",
$"无法安装 v{version}:文件夹 \"{folderName}\" 已被此版本的另一个版本(不同频道)占用。\n\n为避免静默覆盖现有安装请在后台为该条目配置不同的 \"installFolderTemplate\"(例如 \"PROSERVE-firefighter v{{version}}\"),然后重试安装。",
$"ไม่สามารถติดตั้ง v{version}: โฟลเดอร์ \"{folderName}\" ถูกใช้แล้วโดยเวอร์ชันอื่น (channel ต่างกัน) ของเวอร์ชันนี้\n\nเพื่อหลีกเลี่ยงการเขียนทับการติดตั้งที่มีอยู่โดยไม่แจ้ง โปรดกำหนดค่า \"installFolderTemplate\" ที่แตกต่างกันสำหรับรายการนี้ในหลังบ้าน (เช่น \"PROSERVE-firefighter v{{version}}\") จากนั้นลองติดตั้งใหม่",
$"لا يمكن تثبيت v{version}: المجلد \"{folderName}\" مستخدم بالفعل بواسطة إصدار آخر (قناة مختلفة) من هذه النسخة.\n\nلتجنب الكتابة فوق التثبيت الموجود دون تنبيه، قم بإعداد \"installFolderTemplate\" مميز لهذا الإدخال في لوحة الإدارة (مثال \"PROSERVE-firefighter v{{version}}\")، ثم أعد المحاولة.",
$"No se puede instalar v{version}: la carpeta «{folderName}» ya está ocupada por otra edición (canal distinto) de esta versión.\n\nPara evitar sobrescribir la instalación existente sin aviso, configura un «installFolderTemplate» distinto para esta entrada en el backoffice (por ejemplo, «PROSERVE-firefighter v{{version}}»), y luego reintenta la instalación.",
$"v{version} kann nicht installiert werden: Der Ordner „{folderName}\" wird bereits von einer anderen Ausgabe (anderer Channel) dieser Version verwendet.\n\nUm ein stilles Überschreiben der bestehenden Installation zu vermeiden, konfigurieren Sie im Backoffice ein anderes installFolderTemplate\" für diesen Eintrag (z. B. „PROSERVE-firefighter v{{version}}\") und wiederholen Sie die Installation."
);
public static string MsgBusy => T(
"Une autre opération est déjà en cours.",
"Another operation is already in progress.",
@@ -219,6 +359,107 @@ public static class Strings
$"فشل التنزيل أو التثبيت:\n\n{detail}"
);
// ---- SHA-256 mismatch après DL : message ciblé avec source + action ----
public static string SourceOvh => T("OVH (CDN public)", "OVH (public CDN)", "OVH公共 CDN", "OVH (CDN สาธารณะ)", "OVH (CDN عام)");
public static string SourcePeer(string host) => T(
$"peer LAN « {host} »", $"LAN peer « {host} »", $"局域网节点 « {host} »",
$"LAN peer « {host} »", $"عقدة LAN « {host} »");
public static string MsgBoxShaMismatch => T(
"Fichier téléchargé corrompu ou obsolète",
"Downloaded file corrupted or outdated",
"下载的文件已损坏或过时",
"ไฟล์ที่ดาวน์โหลดเสียหายหรือล้าสมัย",
"الملف المنزّل تالف أو قديم"
);
public static string MsgShaMismatchBody(string version, string sourceLabel, string expectedSha, string computedSha) => T(
$"Le ZIP de v{version} a été téléchargé depuis {sourceLabel}, mais son SHA-256 ne correspond pas à celui attendu par le manifest.\n\nAttendu (manifest) : {expectedSha}\nCalculé (téléchargé) : {computedSha}\n\nCauses probables :\n • Le cache CDN OVH n'a pas encore expiré après un re-build serveur (TTL 24-72 h) → l'URL est correcte mais le contenu servi est l'ancien\n • L'admin a re-uploadé le ZIP mais n'a pas cliqué « 🔁 Hasher les versions + signer » après → le manifest référence l'ancien SHA\n • SFTP a préservé le mtime du fichier → le cache .hashcache.json du serveur considère le ZIP inchangé et skip le re-hash\n\nLe cache local pour cette version a été automatiquement purgé. Actions à essayer dans l'ordre :\n 1. Côté admin serveur : « 🔁 Hasher les versions + signer » (avec « Force re-hash » si nécessaire)\n 2. Côté launcher : « ↻ Forcer re-téléchargement » dans le menu \"\" puis Installer\n 3. Si toujours échec : compare le SHA attendu ci-dessus avec le sha256 calculé manuellement sur ton ZIP serveur (sha256sum / certutil)",
$"The v{version} ZIP was downloaded from {sourceLabel}, but its SHA-256 does not match the manifest's expected value.\n\nExpected (manifest) : {expectedSha}\nComputed (downloaded): {computedSha}\n\nLikely causes:\n • OVH CDN cache hasn't expired yet after a server rebuild (TTL 24-72h) → URL is correct but stale content is served\n • Admin re-uploaded the ZIP but didn't click « 🔁 Hash + sign » after → manifest references old SHA\n • SFTP preserved the file mtime → server-side .hashcache.json considers the ZIP unchanged and skips re-hashing\n\nLocal cache for this version was automatically purged. Actions to try in order:\n 1. Server-side admin: « 🔁 Hash + sign » (with « Force re-hash » if needed)\n 2. Launcher: « ↻ Force re-download » in the \"\" menu then Install\n 3. Still failing: compare the expected SHA above with the sha256 manually computed on your server ZIP (sha256sum / certutil)",
$"v{version} 的 ZIP 是从 {sourceLabel} 下载的,但其 SHA-256 与清单中预期的值不匹配。\n\n预期清单{expectedSha}\n计算下载{computedSha}\n\n可能的原因\n • 服务器重建后 OVH CDN 缓存尚未过期TTL 24-72 小时)\n • 管理员重新上传 ZIP 后未点击「🔁 哈希 + 签名」→ 清单引用旧 SHA\n • SFTP 保留了文件 mtime → 服务器端缓存认为 ZIP 未变更并跳过重新哈希\n\n此版本的本地缓存已自动清除。",
$"ZIP ของ v{version} ดาวน์โหลดจาก {sourceLabel} แต่ SHA-256 ไม่ตรงกับค่าที่คาดหวังจาก manifest\n\nคาดหวัง (manifest) : {expectedSha}\nคำนวณ (ดาวน์โหลด) : {computedSha}\n\nสาเหตุที่เป็นไปได้:\n • แคช OVH CDN ยังไม่หมดอายุ\n • Admin อัปโหลด ZIP ใหม่แต่ไม่กด « 🔁 Hash + sign »\n • SFTP รักษา mtime ไฟล์\n\nแคชในเครื่องถูกล้างอัตโนมัติแล้ว",
$"تم تنزيل ZIP v{version} من {sourceLabel}، لكن SHA-256 لا يطابق البيان.\n\nمتوقع (البيان) : {expectedSha}\nمحسوب (المنزل) : {computedSha}\n\nالأسباب المحتملة:\n • لم تنته ذاكرة CDN OVH بعد\n • أعاد المسؤول رفع ZIP دون النقر « 🔁 Hash + sign »\n • حافظ SFTP على mtime الملف\n\nتم مسح ذاكرة التخزين المؤقت المحلية تلقائياً."
);
public static string StatusShaMismatch(string version) => T(
$"⚠ v{version} : SHA-256 invalide — cache purgé, réessaye",
$"⚠ v{version}: SHA-256 invalid — cache purged, retry",
$"⚠ v{version}SHA-256 无效 — 缓存已清除,请重试",
$"⚠ v{version}: SHA-256 ไม่ถูกต้อง — แคชถูกล้าง ลองใหม่",
$"⚠ v{version}: SHA-256 غير صالح — تم مسح الذاكرة، أعد المحاولة"
);
// ---- HTTP 404 mid-DL : manifest local obsolète (fichier renommé serveur) ----
public static string MsgBoxStaleManifest => T(
"Manifest local obsolète",
"Local manifest outdated",
"本地清单已过时",
"Manifest ในเครื่องล้าสมัย",
"البيان المحلي قديم"
);
public static string MsgStaleManifestBody(string version) => T(
$"Le fichier ZIP de v{version} a été renommé ou supprimé côté serveur (typique d'un re-upload pour invalider le cache OVH CDN).\n\nLe launcher a automatiquement re-fetché le manifest depuis le serveur — la nouvelle URL devrait maintenant être connue.\n\nRe-clique « Installer » pour réessayer avec l'URL fraîche.",
$"The v{version} ZIP file was renamed or deleted on the server (typical of a re-upload to invalidate OVH CDN cache).\n\nThe launcher has automatically re-fetched the manifest from the server — the new URL should now be known.\n\nClick « Install » again to retry with the fresh URL.",
$"v{version} 的 ZIP 文件已在服务器端重命名或删除(典型的重新上传以使 OVH CDN 缓存失效)。\n\n启动器已自动从服务器重新获取清单 — 新 URL 现在应该已知。\n\n再次点击 \"\" 以使用新 URL 重试。",
$"ไฟล์ ZIP ของ v{version} ถูกเปลี่ยนชื่อหรือลบบนเซิร์ฟเวอร์ (ทั่วไปจากการอัปโหลดใหม่เพื่อล้างแคช OVH CDN)\n\nLauncher ดึง manifest จากเซิร์ฟเวอร์อัตโนมัติแล้ว — ตอนนี้ควรรู้ URL ใหม่\n\nคลิก \"\" อีกครั้งเพื่อลองด้วย URL ใหม่",
$"تم إعادة تسمية أو حذف ملف ZIP الخاص بـ v{version} من جانب الخادم (نموذجي لإعادة الرفع لإبطال ذاكرة التخزين المؤقت لـ OVH CDN).\n\nقام المشغل بإعادة جلب البيان تلقائياً من الخادم — يجب أن يكون عنوان URL الجديد معروفاً الآن.\n\nانقر فوق \"تثبيت\" مرة أخرى لإعادة المحاولة باستخدام عنوان URL الجديد."
);
public static string StatusManifestRefreshed => T(
"Manifest rafraîchi — re-clique Installer",
"Manifest refreshed — click Install again",
"清单已刷新 — 再次点击安装",
"Manifest รีเฟรชแล้ว — คลิกติดตั้งอีกครั้ง",
"تم تحديث البيان — انقر فوق تثبيت مرة أخرى"
);
// ---- HTTP 416 : range demandé hors du fichier (file plus petit que sizeBytes) ----
public static string MsgBoxSizeMismatch => T(
"Taille de fichier incohérente sur le serveur",
"Server file size mismatch",
"服务器文件大小不一致",
"ขนาดไฟล์เซิร์ฟเวอร์ไม่ตรงกัน",
"حجم الملف على الخادم غير متطابق"
);
public static string StatusSizeMismatch(string version) => T(
$"⚠ v{version} : taille du ZIP serveur ≠ manifest — re-upload nécessaire",
$"⚠ v{version}: server ZIP size ≠ manifest — re-upload needed",
$"⚠ v{version}:服务器 ZIP 大小 ≠ 清单 — 需要重新上传",
$"⚠ v{version}: ขนาด ZIP เซิร์ฟเวอร์ ≠ manifest — ต้องอัปโหลดใหม่",
$"⚠ v{version}: حجم ZIP الخادم ≠ البيان — مطلوب إعادة الرفع"
);
public static string StatusAutoRetryAfter404(string version) => T(
$"v{version} : manifest rafraîchi, re-lancement automatique du téléchargement…",
$"v{version}: manifest refreshed, auto-retrying download…",
$"v{version}:清单已刷新,自动重试下载…",
$"v{version}: รีเฟรช manifest แล้ว ลองดาวน์โหลดใหม่อัตโนมัติ…",
$"v{version}: تم تحديث البيان، إعادة محاولة التنزيل تلقائياً…"
);
public static string MsgServerSideStaleBody(string version) => T(
$"Le téléchargement de v{version} échoue toujours en 404 même APRÈS un refresh du manifest depuis le serveur.\n\nDiagnostic :\n • Le manifest serveur lui-même est obsolète — il référence l'ancien nom de fichier qui n'existe plus.\n • Action côté serveur requise : le manifest doit être mis à jour pour pointer vers le nouveau nom du ZIP (ou re-uploader avec l'ancien nom).\n\nVérifie le backoffice serveur. Une fois le manifest corrigé, clique « Vérifier les MAJ » puis « Installer ».",
$"v{version} download still fails with 404 EVEN AFTER refreshing the manifest from the server.\n\nDiagnosis:\n • The server-side manifest itself is outdated — it references the old filename which no longer exists.\n • Server-side action required: the manifest must be updated to point to the new ZIP filename (or re-upload with the old name).\n\nCheck the server backoffice. Once the manifest is fixed, click « Check Updates » then « Install ».",
$"v{version} 下载即使在从服务器刷新清单后仍因 404 失败。\n\n诊断\n • 服务器端清单本身已过时 — 它引用了不再存在的旧文件名。\n • 需要服务器端操作:清单必须更新以指向新的 ZIP 文件名(或以旧名称重新上传)。\n\n检查服务器后台。修复清单后点击 \"\" 然后 \"安装\"。",
$"การดาวน์โหลด v{version} ยังคงล้มเหลวด้วย 404 แม้หลังจากรีเฟรช manifest จากเซิร์ฟเวอร์\n\nการวินิจฉัย:\n • Manifest ฝั่งเซิร์ฟเวอร์เองล้าสมัย — อ้างถึงชื่อไฟล์เก่าที่ไม่มีอยู่แล้ว\n • ต้องดำเนินการฝั่งเซิร์ฟเวอร์: manifest ต้องอัปเดตให้ชี้ไปยังชื่อ ZIP ใหม่ (หรืออัปโหลดใหม่ด้วยชื่อเก่า)\n\nตรวจสอบ backoffice เซิร์ฟเวอร์ เมื่อ manifest ถูกแก้ไขแล้ว ให้คลิก \"\" จากนั้น \"ติดตั้ง\"",
$"لا يزال تنزيل v{version} يفشل بـ 404 حتى بعد تحديث البيان من الخادم.\n\nالتشخيص:\n • البيان من جانب الخادم نفسه قديم — يشير إلى اسم الملف القديم الذي لم يعد موجوداً.\n • مطلوب إجراء من جانب الخادم: يجب تحديث البيان للإشارة إلى اسم ZIP الجديد (أو إعادة الرفع بالاسم القديم).\n\nتحقق من backoffice الخادم. بعد إصلاح البيان، انقر فوق \"التحقق من التحديثات\" ثم \"تثبيت\"."
);
public static string MenuForceFreshDownload => T(
"↻ Forcer re-téléchargement (vider cache local)",
"↻ Force re-download (purge local cache)",
"↻ 强制重新下载(清除本地缓存)",
"↻ บังคับดาวน์โหลดใหม่ (ล้างแคช)",
"↻ فرض إعادة التنزيل (مسح الذاكرة)"
);
public static string MsgBoxForceFresh => T(
"Forcer le re-téléchargement",
"Force re-download",
"强制重新下载",
"บังคับดาวน์โหลดใหม่",
"فرض إعادة التنزيل"
);
public static string MsgForceFreshConfirm(string version) => T(
$"Vider le cache local pour v{version} et forcer un re-téléchargement complet ?\n\nCela supprimera le ZIP cache + le sidecar SHA + tout partial de DL en cours pour cette version. Utile quand le SHA-256 a changé côté serveur et que la source précédente était obsolète.",
$"Purge the local cache for v{version} and force a full re-download?\n\nThis will delete the cached ZIP + SHA sidecar + any in-progress partial for this version. Useful when the server-side SHA-256 has changed and the previous source was stale.",
$"清除 v{version} 的本地缓存并强制完全重新下载?\n\n这将删除缓存的 ZIP + SHA sidecar + 此版本的任何进行中的部分下载。当服务器端 SHA-256 已更改且之前的源已过时时很有用。",
$"ล้างแคชในเครื่องสำหรับ v{version} และบังคับให้ดาวน์โหลดใหม่ทั้งหมด?\n\nสิ่งนี้จะลบ ZIP ที่แคช + SHA sidecar + การดาวน์โหลดบางส่วนที่กำลังดำเนินการสำหรับเวอร์ชันนี้ มีประโยชน์เมื่อ SHA-256 ฝั่งเซิร์ฟเวอร์เปลี่ยนแปลงและแหล่งที่มาก่อนหน้านี้ล้าสมัย",
$"مسح ذاكرة التخزين المؤقت المحلية لـ v{version} وفرض إعادة تنزيل كاملة؟\n\nسيؤدي هذا إلى حذف ZIP المخزن مؤقتاً + SHA sidecar + أي تنزيل جزئي قيد التقدم لهذا الإصدار. مفيد عندما يتم تغيير SHA-256 على جانب الخادم وكان المصدر السابق قديماً."
);
public static string MsgUninstallFailed(string detail) => T(
$"Suppression échouée : {detail}",
$"Uninstall failed: {detail}",
@@ -352,6 +593,16 @@ public static class Strings
$"تم تثبيت v{version} بنجاح"
);
public static string StatusCopyingSaveGames(string fromVersion, int fileCount) => T(
$"Copie des sauvegardes et replays depuis v{fromVersion} ({fileCount} fichier{(fileCount > 1 ? "s" : "")})…",
$"Copying save games and replays from v{fromVersion} ({fileCount} file{(fileCount > 1 ? "s" : "")})…",
$"正在从 v{fromVersion} 复制存档和录像({fileCount} 个文件)…",
$"กำลังคัดลอกไฟล์เซฟและรีเพลย์จาก v{fromVersion} ({fileCount} ไฟล์)…",
$"جارٍ نسخ حفظات اللعبة والإعادات من v{fromVersion} ({fileCount} ملف)…",
$"Copiando partidas guardadas y repeticiones desde v{fromVersion} ({fileCount} archivo{(fileCount > 1 ? "s" : "")})…",
$"Spielstände und Replays werden von v{fromVersion} kopiert ({fileCount} Datei{(fileCount > 1 ? "en" : "")})…"
);
public static string StatusUninstallingVersion(string version) => T(
$"Suppression v{version}…",
$"Uninstalling v{version}…",
@@ -401,6 +652,474 @@ public static class Strings
$"🔍 التحقق من SHA-256 v{version}: {percent}%"
);
// ==================== DB MIGRATIONS ====================
public static string StatusApplyingMigrations(string version) => T(
$"Application des migrations base de données v{version}…",
$"Applying database migrations for v{version}…",
$"正在应用 v{version} 的数据库迁移…",
$"กำลังปรับใช้การโยกย้ายฐานข้อมูล v{version}…",
$"جارٍ تطبيق ترقيات قاعدة البيانات v{version}…"
);
public static string ProgressMigration(int done, int total, string filename) => T(
$"🗄 Migration {done}/{total} : {filename}",
$"🗄 Migration {done}/{total}: {filename}",
$"🗄 迁移 {done}/{total}{filename}",
$"🗄 การโยกย้าย {done}/{total}: {filename}",
$"🗄 الترقية {done}/{total}: {filename}"
);
public static string MsgBoxMigrationFailed => T("Migration DB échouée", "DB migration failed", "数据库迁移失败", "การโยกย้าย DB ล้มเหลว", "فشل ترقية قاعدة البيانات");
// ==================== REPORT TOOL DEPLOY ====================
public static string StatusDeployingReport(string version) => T(
$"Déploiement de l'outil Report v{version}…",
$"Deploying Report tool for v{version}…",
$"正在部署 v{version} 的报告工具…",
$"กำลังปรับใช้เครื่องมือรายงาน v{version}…",
$"جارٍ نشر أداة التقارير v{version}…"
);
public static string ProgressReportDeploy(int done, int total, string filename) => T(
$"📂 Déploiement Report : {done}/{total} — {filename}",
$"📂 Deploying Report: {done}/{total} — {filename}",
$"📂 部署报告:{done}/{total} — {filename}",
$"📂 ปรับใช้รายงาน: {done}/{total} — {filename}",
$"📂 نشر التقرير: {done}/{total} — {filename}"
);
public static string MsgBoxReportDeployFailed => T("Déploiement Report échoué", "Report deploy failed", "报告部署失败", "การปรับใช้รายงานล้มเหลว", "فشل نشر التقرير");
public static string MsgReportXamppNotFound(string path) => T(
$"L'outil Report n'a pas pu être déployé : le dossier XAMPP {path} est introuvable.\n\nVérifie que XAMPP est installé à cet emplacement (par défaut C:\\xampp) ou ajuste le chemin dans Paramètres → Avancés → Outil Report.",
$"The Report tool could not be deployed: XAMPP folder {path} not found.\n\nMake sure XAMPP is installed there (default C:\\xampp) or adjust the path in Settings → Advanced → Report Tool.",
$"无法部署报告工具:找不到 XAMPP 文件夹 {path}。\n\n请确保 XAMPP 安装在该位置(默认 C:\\xampp或在 设置 → 高级 → 报告工具 中调整路径。",
$"ไม่สามารถปรับใช้เครื่องมือรายงานได้: ไม่พบโฟลเดอร์ XAMPP {path}\n\nตรวจสอบว่า XAMPP ติดตั้งที่ตำแหน่งนั้น (ค่าเริ่มต้น C:\\xampp) หรือปรับเส้นทางใน ตั้งค่า → ขั้นสูง → เครื่องมือรายงาน",
$"تعذر نشر أداة التقارير: مجلد XAMPP {path} غير موجود.\n\nتأكد من تثبيت XAMPP في هذا الموقع (الافتراضي C:\\xampp) أو اضبط المسار في الإعدادات → متقدم → أداة التقارير."
);
public static string MsgReportDeployFailed(string detail) => T(
$"Le déploiement de l'outil Report a échoué :\n\n{detail}\n\nLa version PROSERVE est installée mais l'onglet Statistiques pourrait être désynchronisé. Tu peux retenter via Paramètres → Avancés → Re-déployer le Report.",
$"Report tool deploy failed:\n\n{detail}\n\nThe PROSERVE version is installed but the Reports tab might be out of sync. You can retry via Settings → Advanced → Re-deploy Report.",
$"报告工具部署失败:\n\n{detail}\n\nPROSERVE 版本已安装,但报告选项卡可能不同步。您可以通过 设置 → 高级 → 重新部署报告 重试。",
$"การปรับใช้เครื่องมือรายงานล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วแต่แท็บรายงานอาจไม่ซิงค์ คุณสามารถลองใหม่ผ่าน ตั้งค่า → ขั้นสูง → ปรับใช้รายงานใหม่",
$"فشل نشر أداة التقارير:\n\n{detail}\n\nنسخة PROSERVE مثبتة لكن علامة التبويب التقارير قد لا تكون متزامنة. يمكنك إعادة المحاولة عبر الإعدادات → متقدم → إعادة نشر التقرير."
);
public static string SettingsReportTool => T("OUTIL REPORT (XAMPP htdocs)", "REPORT TOOL (XAMPP htdocs)", "报告工具 (XAMPP htdocs)", "เครื่องมือรายงาน (XAMPP htdocs)", "أداة التقارير (XAMPP htdocs)");
public static string SettingsReportHtdocs => T("Racine XAMPP htdocs", "XAMPP htdocs root", "XAMPP htdocs 根目录", "รากของ XAMPP htdocs", "جذر XAMPP htdocs");
public static string SettingsReportFolder => T("Nom du dossier de déploiement", "Deploy folder name", "部署文件夹名称", "ชื่อโฟลเดอร์ปรับใช้", "اسم مجلد النشر");
public static string SettingsReportAutoDeploy => T(
"Déployer automatiquement l'outil Report à l'install d'une nouvelle version",
"Automatically deploy the Report tool when installing a new version",
"安装新版本时自动部署报告工具",
"ปรับใช้เครื่องมือรายงานโดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่",
"نشر أداة التقارير تلقائياً عند تثبيت نسخة جديدة"
);
public static string SettingsReportRedeploy => T("📂 Re-déployer le Report maintenant", "📂 Re-deploy Report now", "📂 立即重新部署报告", "📂 ปรับใช้รายงานใหม่เดี๋ยวนี้", "📂 إعادة نشر التقرير الآن");
// ---- Doc tool (mêmes mécanique que Report, autres libellés) ----
public static string StatusDeployingDoc(string version) => T(
$"Déploiement de la Documentation v{version}…",
$"Deploying Documentation for v{version}…",
$"正在部署 v{version} 的文档…",
$"กำลังปรับใช้เอกสาร v{version}…",
$"جارٍ نشر التوثيق v{version}…"
);
public static string ProgressDocDeploy(int done, int total, string filename) => T(
$"📖 Déploiement Doc : {done}/{total} — {filename}",
$"📖 Deploying Doc: {done}/{total} — {filename}",
$"📖 部署文档:{done}/{total} — {filename}",
$"📖 ปรับใช้เอกสาร: {done}/{total} — {filename}",
$"📖 نشر التوثيق: {done}/{total} — {filename}"
);
public static string MsgBoxDocDeployFailed => T("Déploiement Doc échoué", "Doc deploy failed", "文档部署失败", "การปรับใช้เอกสารล้มเหลว", "فشل نشر التوثيق");
public static string MsgDocDeployFailed(string detail) => T(
$"Le déploiement de la Documentation a échoué :\n\n{detail}\n\nLa version PROSERVE est installée mais l'onglet Documentation pourrait être désynchronisé. Tu peux retenter via Paramètres → Avancés → Re-déployer la Doc.",
$"Documentation deploy failed:\n\n{detail}\n\nThe PROSERVE version is installed but the Documentation tab might be out of sync. You can retry via Settings → Advanced → Re-deploy Doc.",
$"文档部署失败:\n\n{detail}\n\nPROSERVE 版本已安装,但文档选项卡可能不同步。您可以通过 设置 → 高级 → 重新部署文档 重试。",
$"การปรับใช้เอกสารล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วแต่แท็บเอกสารอาจไม่ซิงค์ คุณสามารถลองใหม่ผ่าน ตั้งค่า → ขั้นสูง → ปรับใช้เอกสารใหม่",
$"فشل نشر التوثيق:\n\n{detail}\n\nنسخة PROSERVE مثبتة لكن علامة التبويب التوثيق قد لا تكون متزامنة. يمكنك إعادة المحاولة عبر الإعدادات → متقدم → إعادة نشر التوثيق."
);
public static string SettingsDocTool => T("OUTIL DOCUMENTATION (XAMPP htdocs)", "DOCUMENTATION TOOL (XAMPP htdocs)", "文档工具 (XAMPP htdocs)", "เครื่องมือเอกสาร (XAMPP htdocs)", "أداة التوثيق (XAMPP htdocs)");
public static string SettingsDocAutoDeploy => T(
"Déployer automatiquement la Documentation à l'install d'une nouvelle version",
"Automatically deploy the Documentation when installing a new version",
"安装新版本时自动部署文档",
"ปรับใช้เอกสารโดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่",
"نشر التوثيق تلقائياً عند تثبيت نسخة جديدة"
);
public static string SettingsDocRedeploy => T("📖 Re-déployer la Doc maintenant", "📖 Re-deploy Doc now", "📖 立即重新部署文档", "📖 ปรับใช้เอกสารใหม่เดี๋ยวนี้", "📖 إعادة نشر التوثيق الآن");
public static string SettingsDocBackups => SettingsReportBackups;
public static string SettingsDocMaxBackups => SettingsReportMaxBackups;
// ---- API tool (mêmes mécanique que Report/Doc, autres libellés) ----
public static string StatusDeployingApi(string version) => T(
$"Déploiement de l'API v{version}…",
$"Deploying API for v{version}…",
$"正在部署 v{version} 的 API…",
$"กำลังปรับใช้ API v{version}…",
$"جارٍ نشر واجهة برمجة التطبيقات v{version}…"
);
public static string ProgressApiDeploy(int done, int total, string filename) => T(
$"🔌 Déploiement API : {done}/{total} — {filename}",
$"🔌 Deploying API: {done}/{total} — {filename}",
$"🔌 部署 API{done}/{total} — {filename}",
$"🔌 ปรับใช้ API: {done}/{total} — {filename}",
$"🔌 نشر API: {done}/{total} — {filename}"
);
public static string MsgBoxApiDeployFailed => T("Déploiement API échoué", "API deploy failed", "API 部署失败", "การปรับใช้ API ล้มเหลว", "فشل نشر API");
public static string MsgApiDeployFailed(string detail) => T(
$"Le déploiement de l'API a échoué :\n\n{detail}\n\nLa version PROSERVE est installée mais l'API de stats pourrait être désynchronisée. Tu peux retenter via Paramètres → Avancés → Re-déployer l'API.",
$"API deploy failed:\n\n{detail}\n\nThe PROSERVE version is installed but the stats API might be out of sync. You can retry via Settings → Advanced → Re-deploy API.",
$"API 部署失败:\n\n{detail}\n\nPROSERVE 版本已安装,但统计 API 可能不同步。您可以通过 设置 → 高级 → 重新部署 API 重试。",
$"การปรับใช้ API ล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วแต่ API สถิติอาจไม่ซิงค์ คุณสามารถลองใหม่ผ่าน ตั้งค่า → ขั้นสูง → ปรับใช้ API ใหม่",
$"فشل نشر API:\n\n{detail}\n\nنسخة PROSERVE مثبتة لكن API الإحصائيات قد لا يكون متزامناً. يمكنك إعادة المحاولة عبر الإعدادات → متقدم → إعادة نشر API."
);
public static string SettingsApiTool => T("API PHP DE STATS (XAMPP htdocs)", "STATS PHP API (XAMPP htdocs)", "统计 PHP API (XAMPP htdocs)", "API PHP สถิติ (XAMPP htdocs)", "واجهة API PHP للإحصائيات (XAMPP htdocs)");
public static string SettingsApiAutoDeploy => T(
"Déployer automatiquement l'API à l'install d'une nouvelle version",
"Automatically deploy the API when installing a new version",
"安装新版本时自动部署 API",
"ปรับใช้ API โดยอัตโนมัติเมื่อติดตั้งเวอร์ชันใหม่",
"نشر API تلقائياً عند تثبيت نسخة جديدة"
);
public static string SettingsApiRedeploy => T("🔌 Re-déployer l'API maintenant", "🔌 Re-deploy API now", "🔌 立即重新部署 API", "🔌 ปรับใช้ API ใหม่เดี๋ยวนี้", "🔌 إعادة نشر API الآن");
public static string SettingsApiBackups => SettingsReportBackups;
public static string SettingsApiMaxBackups => SettingsReportMaxBackups;
// ---- SteamVR settings merge (tue SteamVR + Vive Business Streaming + merge JSON) ----
public static string StatusDeployingSteamVr(string version) => T(
$"Configuration SteamVR pour v{version}…",
$"Configuring SteamVR for v{version}…",
$"正在为 v{version} 配置 SteamVR…",
$"กำลังกำหนดค่า SteamVR สำหรับ v{version}…",
$"جارٍ تهيئة SteamVR لـ v{version}…"
);
public static string MsgBoxSteamVrMergeFailed => T(
"Configuration SteamVR échouée",
"SteamVR configuration failed",
"SteamVR 配置失败",
"การกำหนดค่า SteamVR ล้มเหลว",
"فشل تهيئة SteamVR"
);
public static string MsgSteamVrPreFlightTitle => T(
"Fermeture de SteamVR & Vive Business Streaming",
"Closing SteamVR & Vive Business Streaming",
"正在关闭 SteamVR 和 Vive Business Streaming",
"ปิด SteamVR และ Vive Business Streaming",
"إغلاق SteamVR و Vive Business Streaming"
);
public static string MsgSteamVrPreFlightBody(string processesPreview, int rootKeysChanging) => T(
$"{rootKeysChanging} bloc(s) de la configuration SteamVR vont être mis à jour.\n\nPour appliquer ces changements (mapping trackers, bindings), le launcher doit fermer ces programmes :\n\n • {processesPreview}\n\nIls pourront être relancés normalement après l'install. Continuer ?",
$"{rootKeysChanging} SteamVR config block(s) will be updated.\n\nTo apply these changes (tracker mappings, bindings), the launcher must close these programs:\n\n • {processesPreview}\n\nYou'll be able to relaunch them normally after the install. Proceed?",
$"将更新 {rootKeysChanging} 个 SteamVR 配置块。\n\n为应用这些更改追踪器映射、绑定启动器必须关闭这些程序\n\n • {processesPreview}\n\n安装完成后您可以正常重新启动它们。继续吗",
$"จะอัปเดตบล็อกการกำหนดค่า SteamVR {rootKeysChanging} บล็อก\n\nเพื่อใช้การเปลี่ยนแปลงเหล่านี้ (การจับคู่ตัวติดตาม การผูก) ตัวเปิดต้องปิดโปรแกรมเหล่านี้:\n\n • {processesPreview}\n\nคุณสามารถเปิดใหม่ได้ตามปกติหลังติดตั้ง ดำเนินการต่อ?",
$"سيتم تحديث {rootKeysChanging} كتلة (كتل) من تكوين SteamVR.\n\nلتطبيق هذه التغييرات (تعيينات أجهزة التتبع، الارتباطات)، يجب أن يغلق المشغل هذه البرامج:\n\n • {processesPreview}\n\nيمكنك إعادة تشغيلها بشكل طبيعي بعد التثبيت. متابعة؟"
);
public static string MsgSteamVrMergeFailed(string detail) => T(
$"Le merge des settings SteamVR a échoué :\n\n{detail}\n\nLa version PROSERVE est installée et fonctionnera, mais le mapping des trackers Vive Business Streaming pourrait être incorrect. Vérifie depuis SteamVR → Paramètres → Périphériques après le premier lancement.",
$"SteamVR settings merge failed:\n\n{detail}\n\nThe PROSERVE version is installed and will run, but Vive Business Streaming tracker bindings might be off. Verify from SteamVR → Settings → Devices after the first launch.",
$"SteamVR 设置合并失败:\n\n{detail}\n\nPROSERVE 版本已安装且将运行,但 Vive Business Streaming 跟踪器绑定可能不正确。首次启动后请通过 SteamVR → 设置 → 设备 进行检查。",
$"การรวมการตั้งค่า SteamVR ล้มเหลว:\n\n{detail}\n\nเวอร์ชัน PROSERVE ติดตั้งแล้วและจะทำงาน แต่การจับคู่ตัวติดตาม Vive Business Streaming อาจไม่ถูกต้อง ตรวจสอบจาก SteamVR → การตั้งค่า → อุปกรณ์ หลังเปิดครั้งแรก",
$"فشل دمج إعدادات SteamVR:\n\n{detail}\n\nنسخة PROSERVE مثبتة وستعمل، لكن قد يكون ربط أجهزة تتبع Vive Business Streaming غير صحيح. تحقق من SteamVR ← الإعدادات ← الأجهزة بعد التشغيل الأول."
);
public static string SettingsSteamVr => T(
"STEAMVR + VIVE BUSINESS STREAMING",
"STEAMVR + VIVE BUSINESS STREAMING",
"STEAMVR + VIVE BUSINESS STREAMING",
"STEAMVR + VIVE BUSINESS STREAMING",
"STEAMVR + VIVE BUSINESS STREAMING"
);
public static string SettingsSteamVrAutoMerge => T(
"Fusionner steamvr.vrsettings à l'install (tue SteamVR + Vive Business Streaming, modifie le JSON, laisse SteamVR redémarrer au prochain lancement PROSERVE)",
"Merge steamvr.vrsettings on install (kills SteamVR + Vive Business Streaming, edits the JSON, lets SteamVR restart on next PROSERVE launch)",
"安装时合并 steamvr.vrsettings终止 SteamVR + Vive Business Streaming编辑 JSON下次启动 PROSERVE 时让 SteamVR 重新启动)",
"รวม steamvr.vrsettings เมื่อติดตั้ง (ปิด SteamVR + Vive Business Streaming, แก้ JSON, ปล่อยให้ SteamVR เริ่มใหม่เมื่อเปิด PROSERVE ครั้งถัดไป)",
"دمج steamvr.vrsettings عند التثبيت (إنهاء SteamVR + Vive Business Streaming، تعديل JSON، السماح لـ SteamVR بإعادة التشغيل عند تشغيل PROSERVE التالي)"
);
public static string SettingsSteamVrSettingsPath => T(
"Chemin vers steamvr.vrsettings (vide = auto-détection via registre Steam)",
"Path to steamvr.vrsettings (empty = auto-detect via Steam registry)",
"steamvr.vrsettings 路径(空 = 通过 Steam 注册表自动检测)",
"พาธไปยัง steamvr.vrsettings (ว่าง = ตรวจหาอัตโนมัติผ่าน Steam registry)",
"مسار steamvr.vrsettings (فارغ = اكتشاف تلقائي عبر سجل Steam)"
);
public static string SettingsSteamVrProcesses => T(
"Processus à tuer avant le merge (un nom par ligne, sans .exe). Vive Business Streaming doit être en tête car il relance SteamVR.",
"Processes to kill before merging (one name per line, no .exe). Vive Business Streaming must be first because it auto-restarts SteamVR.",
"合并前要终止的进程(每行一个名称,无 .exe。Vive Business Streaming 必须放在第一位,因为它会自动重启 SteamVR。",
"โพรเซสที่ต้องปิดก่อนรวม (หนึ่งชื่อต่อบรรทัด ไม่ใส่ .exe) Vive Business Streaming ต้องอยู่บนสุดเพราะมันรีสตาร์ท SteamVR อัตโนมัติ",
"العمليات التي يجب إنهاؤها قبل الدمج (اسم واحد لكل سطر، بدون .exe). يجب أن يكون Vive Business Streaming في المقدمة لأنه يعيد تشغيل SteamVR تلقائياً."
);
// ---- Cache LAN P2P ----
public static string StatusDownloadingFromPeer(string host, string version) => T(
$"📡 Téléchargement de v{version} depuis {host} (LAN)…",
$"📡 Downloading v{version} from {host} (LAN)…",
$"📡 正在从 {host} 下载 v{version} (局域网)…",
$"📡 กำลังดาวน์โหลด v{version} จาก {host} (LAN)…",
$"📡 جارٍ تنزيل v{version} من {host} (LAN)…"
);
public static string SettingsLanCache => T("CACHE LAN P2P", "LAN P2P CACHE", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P");
public static string SettingsLanCacheServerEnabled => T(
"Activer le mode serveur (partager les ZIPs aux PCs du LAN)",
"Enable server mode (share ZIPs to LAN PCs)",
"启用服务器模式(与局域网 PC 共享 ZIP",
"เปิดใช้โหมดเซิร์ฟเวอร์ (แชร์ ZIP ไปยัง PC ใน LAN)",
"تمكين وضع الخادم (مشاركة ZIPs مع أجهزة الكمبيوتر في LAN)"
);
public static string SettingsLanCacheClientEnabled => T(
"Activer le mode client (chercher les ZIPs sur le LAN avant OVH)",
"Enable client mode (look for ZIPs on the LAN before OVH)",
"启用客户端模式(在 OVH 之前在局域网上查找 ZIP",
"เปิดใช้โหมดไคลเอนต์ (ค้นหา ZIP บน LAN ก่อน OVH)",
"تمكين وضع العميل (البحث عن ZIPs على LAN قبل OVH)"
);
public static string SettingsLanCacheServerPort => T("Port HTTP serveur", "Server HTTP port", "服务器 HTTP 端口", "พอร์ต HTTP เซิร์ฟเวอร์", "منفذ HTTP الخادم");
public static string SettingsLanCacheDiscoveryPort => T("Port UDP découverte", "Discovery UDP port", "发现 UDP 端口", "พอร์ต UDP การค้นพบ", "منفذ UDP الاكتشاف");
public static string SettingsLanCacheMaxCached => T("ZIPs en cache (N)", "Cached ZIPs (N)", "缓存 ZIP (N)", "ZIP ในแคช (N)", "ZIPs المخزنة (N)");
public static string SettingsLanCacheDiscoveredPeers => T("Peers découverts automatiquement", "Auto-discovered peers", "自动发现的 Peers", "Peers ที่ค้นพบอัตโนมัติ", "النظراء المكتشفون تلقائياً");
public static string SettingsLanCacheManualPeers => T("Peers manuels (1 URL par ligne, ex: http://10.0.0.5:47623)", "Manual peers (1 URL per line, e.g. http://10.0.0.5:47623)", "手动 Peers每行 1 个 URL例如 http://10.0.0.5:47623", "Peers ด้วยตนเอง (1 URL ต่อบรรทัด เช่น http://10.0.0.5:47623)", "النظراء يدوياً (URL واحد لكل سطر، مثل http://10.0.0.5:47623)");
public static string SettingsLanCacheNoDiscovered => T(
"Aucun peer détecté pour le moment (les beacons sont émis toutes les 15 s).",
"No peer detected yet (beacons are sent every 15 s).",
"目前未检测到任何 peer (信标每 15 秒发送一次).",
"ยังไม่มี peer ตรวจพบ (สัญญาณถูกส่งทุก 15 วินาที).",
"لم يتم اكتشاف أي peer بعد (يتم إرسال إشارات كل 15 ثانية)."
);
public static string SettingsLanCacheServerStatus(string endpoints, int versions) => T(
$"✓ Serveur actif sur {endpoints} — {versions} version(s) en cache",
$"✓ Server running on {endpoints} — {versions} version(s) cached",
$"✓ 服务器正在 {endpoints} 上运行 — 缓存了 {versions} 个版本",
$"✓ เซิร์ฟเวอร์ทำงานบน {endpoints} — แคช {versions} เวอร์ชัน",
$"✓ الخادم يعمل على {endpoints} — {versions} نسخة مخزنة"
);
public static string SettingsLanCacheServerOff => T(
"Mode serveur désactivé — ce PC ne sert pas les ZIPs aux peers du LAN.",
"Server mode disabled — this PC does not share ZIPs to LAN peers.",
"服务器模式已禁用 — 此 PC 不会与局域网 peers 共享 ZIP。",
"โหมดเซิร์ฟเวอร์ถูกปิด — PC นี้จะไม่แชร์ ZIP ไปยัง LAN peers",
"وضع الخادم معطل — هذا الكمبيوتر لا يشارك ZIPs مع نظراء LAN."
);
public static string MsgBoxLanCacheChange => T("Cache LAN P2P", "LAN P2P cache", "局域网 P2P 缓存", "แคช LAN P2P", "ذاكرة التخزين المؤقت LAN P2P");
// ---- Redist Unreal (VC_redist, UEPrereqSetup, …) ----
public static string StatusInstallingRedists(string version) => T(
$"⚙️ Installation des composants requis pour v{version}…",
$"⚙️ Installing required components for v{version}…",
$"⚙️ 正在为 v{version} 安装必需组件…",
$"⚙️ กำลังติดตั้งส่วนประกอบที่จำเป็นสำหรับ v{version}…",
$"⚙️ جارٍ تثبيت المكونات المطلوبة للنسخة v{version}…"
);
public static string ProgressRedistInstalling(int done, int total, string filename) => T(
$"⚙️ Redist {done}/{total} : {filename}",
$"⚙️ Redist {done}/{total}: {filename}",
$"⚙️ Redist {done}/{total}{filename}",
$"⚙️ Redist {done}/{total}: {filename}",
$"⚙️ Redist {done}/{total}: {filename}"
);
// ---- Mode auto (auto-launch + auto-relaunch après exit) ----
public static string ActionAuto => T("AUTO", "AUTO", "自动", "อัตโนมัติ", "تلقائي", "AUTO", "AUTO");
public static string AutoModeTooltipActive => T(
"Cette version est désignée pour le lancement automatique. Clique pour désactiver.",
"This version is set for auto-launch. Click to disable.",
"此版本设置为自动启动。点击以禁用。",
"เวอร์ชันนี้ถูกตั้งให้เปิดอัตโนมัติ คลิกเพื่อปิด",
"هذه النسخة معيّنة للتشغيل التلقائي. انقر للتعطيل."
);
public static string AutoModeTooltipInactive => T(
"Clique pour désigner cette version comme lancée automatiquement au démarrage du launcher.",
"Click to set this version as auto-launched on launcher startup.",
"点击以设置此版本在启动器启动时自动启动。",
"คลิกเพื่อตั้งเวอร์ชันนี้ให้เปิดอัตโนมัติเมื่อตัวเปิดเริ่มต้น",
"انقر لتعيين هذه النسخة للتشغيل التلقائي عند بدء المشغل."
);
// ---- Modal de lancement auto (startup, post-exit, ou activation manuelle) ----
public static string AutoRelaunchTitle => T("Lancement automatique", "Auto-launch", "自动启动", "เปิดอัตโนมัติ", "التشغيل التلقائي");
public static string AutoRelaunchMessage(string version) => T(
$"Lancement automatique de PROSERVE v{version} dans :",
$"Auto-launching PROSERVE v{version} in:",
$"PROSERVE v{version} 自动启动倒计时:",
$"กำลังเปิด PROSERVE v{version} อัตโนมัติใน:",
$"التشغيل التلقائي لـ PROSERVE v{version} خلال:"
);
public static string AutoRelaunchCancel => T(
"Annuler (désactiver le mode auto)",
"Cancel (disable auto-mode)",
"取消(禁用自动模式)",
"ยกเลิก (ปิดโหมดอัตโนมัติ)",
"إلغاء (تعطيل الوضع التلقائي)"
);
// ---- Phase 1 du modal auto : attente santé système (option WaitForHealthChecks) ----
public static string AutoLaunchHealthWaitTitle => T(
"Vérification de la santé système",
"System health check",
"系统健康检查",
"ตรวจสอบสุขภาพระบบ",
"فحص صحة النظام"
);
public static string AutoLaunchHealthWaitMessage => T(
"En attente que tous les indicateurs système passent au vert avant de lancer PROSERVE…",
"Waiting for all system indicators to turn green before launching PROSERVE…",
"等待所有系统指标变绿后再启动 PROSERVE…",
"รอให้ตัวบ่งชี้ทั้งหมดเป็นสีเขียวก่อนเปิด PROSERVE…",
"في انتظار أن تصبح جميع مؤشرات النظام خضراء قبل تشغيل PROSERVE…"
);
public static string AutoLaunchHealthWaitPending(int count) => T(
$"Indicateurs en attente : {count}",
$"Pending indicators: {count}",
$"待处理指标:{count}",
$"ตัวบ่งชี้ที่รอ: {count}",
$"المؤشرات المعلقة: {count}"
);
// ---- Garde-fou contre double-lancement de PROSERVE ----
public static string MsgAlreadyRunningTitle => T(
"PROSERVE déjà lancé",
"PROSERVE already running",
"PROSERVE 已在运行",
"PROSERVE กำลังทำงานอยู่แล้ว",
"PROSERVE قيد التشغيل بالفعل"
);
public static string MsgAlreadyRunningBody => T(
"Une instance de PROSERVE est déjà en cours d'exécution. Le launcher refuse de la dupliquer pour éviter les conflits (sessions, ports, fichiers verrouillés).\n\nFerme l'instance existante avant d'en lancer une autre.",
"A PROSERVE instance is already running. The launcher refuses to start a second one to avoid conflicts (sessions, ports, locked files).\n\nClose the existing instance before starting another.",
"PROSERVE 实例已在运行。启动器拒绝重复启动以避免冲突(会话、端口、锁定文件)。\n\n请先关闭现有实例再启动新的。",
"มี PROSERVE ทำงานอยู่แล้ว ตัวเปิดปฏิเสธที่จะเริ่มอันที่สองเพื่อหลีกเลี่ยงความขัดแย้ง (เซสชัน, พอร์ต, ไฟล์ที่ถูกล็อก)\n\nปิดอันที่มีอยู่ก่อนเริ่มอันใหม่",
"هناك نسخة من PROSERVE قيد التشغيل بالفعل. يرفض المشغل بدء نسخة ثانية لتجنب التعارضات (الجلسات، المنافذ، الملفات المقفلة).\n\nأغلق النسخة الحالية قبل بدء أخرى."
);
// ---- Section Settings → Avancés → Arguments de lancement (défauts appliqués partout) ----
public static string SettingsLaunchArgs => T(
"ARGUMENTS DE LANCEMENT",
"LAUNCH ARGUMENTS",
"启动参数",
"อาร์กิวเมนต์การเปิด",
"وسائط التشغيل",
"ARGUMENTOS DE INICIO",
"STARTARGUMENTE"
);
public static string SettingsLaunchArgsHelp => T(
"Arguments CLI appliqués à CHAQUE lancement de PROSERVE (mode auto ou manuel). Les arguments du mode auto ci-dessous s'ajoutent en plus, uniquement pour la version désignée AUTO.",
"CLI arguments applied to EVERY PROSERVE launch (auto or manual). Auto mode arguments below are added on top, only for the version marked AUTO.",
"应用于每次 PROSERVE 启动的 CLI 参数(自动或手动)。下方的自动模式参数仅对标记为 AUTO 的版本额外附加。",
"อาร์กิวเมนต์ CLI ที่ใช้กับการเปิด PROSERVE ทุกครั้ง (อัตโนมัติหรือด้วยตนเอง) อาร์กิวเมนต์โหมดอัตโนมัติด้านล่างจะเพิ่มเฉพาะสำหรับเวอร์ชันที่ตั้งเป็น AUTO",
"وسائط CLI مطبقة على كل تشغيل لـ PROSERVE (تلقائي أو يدوي). وسائط الوضع التلقائي أدناه تُضاف فقط للنسخة المحددة AUTO.",
"Argumentos CLI aplicados a CADA inicio de PROSERVE (auto o manual). Los argumentos del modo auto de abajo se añaden encima solo para la versión marcada AUTO.",
"CLI-Argumente, die bei JEDEM Start von PROSERVE angewendet werden (Auto oder manuell). Die Auto-Modus-Argumente unten werden nur für die als AUTO markierte Version zusätzlich angehängt."
);
// ---- Section Settings → Avancés → Mode auto ----
public static string SettingsAutoMode => T("MODE AUTO", "AUTO MODE", "自动模式", "โหมดอัตโนมัติ", "الوضع التلقائي");
public static string SettingsAutoModeFeatureEnabled => T(
"Activer la fonctionnalité mode auto (affiche les boutons AUTO sur la library)",
"Enable auto mode feature (shows AUTO buttons on the library)",
"启用自动模式功能(在库中显示 AUTO 按钮)",
"เปิดใช้คุณสมบัติโหมดอัตโนมัติ (แสดงปุ่ม AUTO ในไลบรารี)",
"تمكين ميزة الوضع التلقائي (إظهار أزرار AUTO في المكتبة)"
);
public static string SettingsAutoModeGrace => T("Délai avant lancement (s)", "Grace period before launch (s)", "启动延迟(秒)", "ดีเลย์ก่อนเปิด (วินาที)", "الفترة قبل التشغيل (ث)");
public static string SettingsAutoModeWaitForHealth => T(
"Attendre que tous les indicateurs de santé système soient OK avant de lancer",
"Wait for all system health indicators to be OK before launching",
"在启动前等待所有系统健康指标为 OK",
"รอให้ตัวบ่งชี้สุขภาพระบบทั้งหมดเป็น OK ก่อนเปิด",
"انتظر حتى تكون جميع مؤشرات صحة النظام بحالة OK قبل التشغيل"
);
public static string SettingsAutoModeArgs => T(
"Arguments CLI passés à PROSERVE. Saisis juste le nom (ex. \"autoconnect\", \"login\") — le launcher ajoute automatiquement les \"-\", \"=\" et les guillemets autour des valeurs avec espaces.",
"CLI arguments passed to PROSERVE. Just type the name (e.g. \"autoconnect\", \"login\") — the launcher auto-adds \"-\", \"=\" and quotes values containing spaces.",
"传递给 PROSERVE 的 CLI 参数。只输入名称(例如 \"autoconnect\"、\"login\"),启动器自动添加 \"-\"、\"=\" 和引号。",
"อาร์กิวเมนต์ CLI ที่ส่งไป PROSERVE พิมพ์เฉพาะชื่อ (เช่น \"autoconnect\", \"login\") — ตัวเปิดจะเพิ่ม \"-\", \"=\" และเครื่องหมายคำพูดให้อัตโนมัติ",
"وسائط CLI الممررة إلى PROSERVE. اكتب الاسم فقط (مثل \"autoconnect\"، \"login\") — يضيف المشغل تلقائياً \"-\" و \"=\" وعلامات الاقتباس."
);
public static string SettingsAutoModeArgKey => T("Clé", "Key", "键", "คีย์", "المفتاح");
public static string SettingsAutoModeArgValue => T("Valeur (optionnel)", "Value (optional)", "值(可选)", "ค่า (ตัวเลือก)", "القيمة (اختياري)");
public static string SettingsAutoModeArgAdd => T("+ Ajouter un argument", "+ Add argument", "+ 添加参数", "+ เพิ่มอาร์กิวเมนต์", "+ إضافة وسيطة");
public static string SettingsAutoModeArgRemove => T("Supprimer", "Remove", "删除", "ลบ", "إزالة");
public static string SettingsAutoModeSelected(string version) => T(
$"Version désignée : v{version}",
$"Selected version: v{version}",
$"已选版本v{version}",
$"เวอร์ชันที่เลือก: v{version}",
$"النسخة المحددة: v{version}"
);
public static string SettingsAutoModeNoneSelected => T(
"Aucune version désignée (clique le bouton AUTO sur une version installée pour la désigner).",
"No version selected (click the AUTO button on an installed version to set it).",
"未选版本(点击已安装版本上的 AUTO 按钮以选择)。",
"ยังไม่ได้เลือกเวอร์ชัน (คลิกปุ่ม AUTO บนเวอร์ชันที่ติดตั้งแล้วเพื่อเลือก)",
"لم يتم اختيار نسخة (انقر على زر AUTO في نسخة مثبتة لتحديدها)."
);
// ---- Settings lock (mot de passe pour déverrouiller la section Avancés) ----
public static string SettingsLockedTitle => T("Paramètres avancés verrouillés", "Advanced settings locked", "高级设置已锁定", "การตั้งค่าขั้นสูงถูกล็อก", "الإعدادات المتقدمة مقفلة");
public static string SettingsLockedBody => T(
"Les paramètres avancés sont protégés par un mot de passe défini par l'administrateur. Saisis-le pour les déverrouiller (déverrouillage valable jusqu'à la fermeture du launcher).",
"Advanced settings are protected by a password set by the administrator. Enter it to unlock (unlock persists until launcher restart).",
"高级设置受管理员设置的密码保护。输入密码以解锁(解锁状态保持到启动器重新启动)。",
"การตั้งค่าขั้นสูงถูกป้องกันด้วยรหัสผ่านที่ผู้ดูแลกำหนด ใส่รหัสเพื่อปลดล็อก (การปลดล็อกคงอยู่จนกว่าจะรีสตาร์ทตัวเปิด)",
"الإعدادات المتقدمة محمية بكلمة مرور حددها المسؤول. أدخلها لإلغاء القفل (إلغاء القفل يستمر حتى إعادة تشغيل المشغل)."
);
public static string SettingsLockedUnlock => T("Déverrouiller", "Unlock", "解锁", "ปลดล็อก", "إلغاء القفل");
public static string SettingsLockedWrongPassword => T(
"Mot de passe incorrect.",
"Incorrect password.",
"密码错误。",
"รหัสผ่านไม่ถูกต้อง",
"كلمة المرور غير صحيحة."
);
// ---- Badge source du manifest (origine de la liste de versions affichée) ----
public static string ManifestSourceOvh => T("🌐 OVH (en ligne)", "🌐 OVH (online)", "🌐 OVH在线", "🌐 OVH (ออนไลน์)", "🌐 OVH (متصل)");
public static string ManifestSourcePeer => T("📡 Peer LAN (hors ligne)", "📡 LAN peer (offline)", "📡 局域网 Peer离线", "📡 LAN Peer (ออฟไลน์)", "📡 LAN Peer (غير متصل)");
public static string ManifestSourceDiskCache => T("💾 Cache local (hors ligne)", "💾 Local cache (offline)", "💾 本地缓存(离线)", "💾 แคชในเครื่อง (ออฟไลน์)", "💾 ذاكرة محلية (غير متصل)");
public static string MsgLanCacheRestart => T(
"Le démarrage / arrêt du serveur ou client LAN nécessite un redémarrage du launcher pour prendre effet.\n\nRedémarrer maintenant ?",
"Starting / stopping the LAN server or client requires a launcher restart to take effect.\n\nRestart now?",
"启动/停止局域网服务器或客户端需要重新启动启动器才能生效。\n\n现在重新启动",
"การเริ่ม/หยุดเซิร์ฟเวอร์หรือไคลเอนต์ LAN ต้องรีสตาร์ทตัวเปิดเพื่อให้มีผล\n\nรีสตาร์ทตอนนี้?",
"يتطلب بدء / إيقاف خادم أو عميل LAN إعادة تشغيل المشغل لتفعيل التغيير.\n\nإعادة التشغيل الآن؟"
);
// ---- Backups Report tool ----
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");
public static string SettingsReportRevert => T("↶ Revert", "↶ Revert", "↶ 还原", "↶ ย้อนกลับ", "↶ استرجاع");
public static string SettingsReportNoBackups => T(
"Aucun backup pour l'instant. Le premier sera créé au prochain deploy.",
"No backups yet. The first one will be created on the next deploy.",
"暂无备份。下次部署时将创建第一个。",
"ยังไม่มีสำรองข้อมูล จะสร้างครั้งแรกในการปรับใช้ครั้งถัดไป",
"لا توجد نسخ احتياطية بعد. سيتم إنشاء أول نسخة عند النشر التالي."
);
public static string SettingsReportBackupSize(string size) => T(
$"taille : {size}", $"size: {size}", $"大小:{size}", $"ขนาด: {size}", $"الحجم: {size}"
);
public static string MsgBoxRevertReport => T("Revert Outil Report", "Revert Report tool", "还原报告工具", "ย้อนกลับเครื่องมือรายงาน", "استرجاع أداة التقارير");
public static string MsgRevertReportConfirm(string date) => T(
$"Revenir à la version du {date} ?\n\nLa version actuellement déployée sera elle-même sauvegardée comme nouveau backup, donc tu pourras y revenir si besoin.",
$"Revert to the version from {date}?\n\nThe currently deployed version will itself be saved as a new backup, so you can switch back if needed.",
$"还原到 {date} 的版本?\n\n当前已部署的版本将作为新备份保存因此您可以在需要时切换回去。",
$"ย้อนกลับไปยังเวอร์ชันของ {date}?\n\nเวอร์ชันที่ปรับใช้อยู่ในปัจจุบันจะถูกบันทึกเป็นสำรองใหม่ ดังนั้นคุณสามารถสลับกลับได้หากต้องการ",
$"العودة إلى نسخة {date}؟\n\nسيتم حفظ النسخة المنشورة حالياً كنسخة احتياطية جديدة، لذا يمكنك التبديل إليها إذا لزم الأمر."
);
public static string MsgRevertReportSuccess(string date) => T(
$"✓ Reverted vers la version du {date}",
$"✓ Reverted to version from {date}",
$"✓ 已还原到 {date} 的版本",
$"✓ ย้อนกลับเป็นเวอร์ชันของ {date} แล้ว",
$"✓ تم الاسترجاع إلى نسخة {date}"
);
public static string MsgMigrationFailed(string detail) => T(
$"Une migration de la base de données a échoué :\n\n{detail}\n\nLa version a été extraite mais la base n'a PAS été modifiée (rollback automatique).\nVérifie que XAMPP est démarré et que les paramètres de connexion sont corrects (Settings → Base de données), puis clique « Rejouer les migrations » dans Settings.",
$"A database migration failed:\n\n{detail}\n\nThe version was extracted but the database was NOT modified (automatic rollback).\nMake sure XAMPP is running and the connection settings are correct (Settings → Database), then click « Replay migrations » in Settings.",
$"数据库迁移失败:\n\n{detail}\n\n版本已解压但数据库未被修改自动回滚。\n请确保 XAMPP 正在运行并且连接设置正确(设置 → 数据库),然后点击设置中的「重新执行迁移」。",
$"การโยกย้ายฐานข้อมูลล้มเหลว:\n\n{detail}\n\nเวอร์ชันถูกแตกแล้วแต่ฐานข้อมูลไม่ถูกแก้ไข (rollback อัตโนมัติ)\nตรวจสอบว่า XAMPP กำลังทำงานและการตั้งค่าการเชื่อมต่อถูกต้อง (ตั้งค่า → ฐานข้อมูล) จากนั้นคลิก « เรียกใช้การโยกย้ายอีกครั้ง » ในการตั้งค่า",
$"فشلت ترقية قاعدة البيانات:\n\n{detail}\n\nتم استخراج النسخة لكن قاعدة البيانات لم تُعدَّل (rollback تلقائي).\nتأكد من تشغيل XAMPP وأن إعدادات الاتصال صحيحة (الإعدادات → قاعدة البيانات)، ثم انقر « إعادة تشغيل الترقيات » في الإعدادات."
);
public static string StatusPreparingDownload(string version) => T(
$"Préparation du téléchargement v{version}…",
$"Preparing download for v{version}…",
@@ -743,6 +1462,124 @@ public static class Strings
$"تاريخ الإصدار: {date} • {size}"
);
// ==================== HEALTH BANNER ====================
public static string SettingsHealthChecks => T(
"BANDEAU DE SANTÉ SYSTÈME",
"SYSTEM HEALTH BANNER",
"系统健康状态栏",
"แถบสถานะระบบ",
"شريط حالة النظام"
);
public static string SettingsHealthChecksHint => T(
"Pings et processus surveillés sous la top bar (Library uniquement). Édité ici, sauvegardé dans config.json — survit à un auto-update.",
"Pings and processes monitored below the top bar (Library only). Edited here, saved to config.json — survives auto-updates.",
"在顶部栏下监控的 Ping 和进程(仅库视图)。在此编辑,保存到 config.json — 在自动更新后保留。",
"Ping และโปรเซสที่ติดตามใต้แถบบนสุด (เฉพาะ Library) แก้ไขที่นี่ บันทึกใน config.json — คงอยู่หลังการอัปเดตอัตโนมัติ",
"Ping والعمليات المراقَبة أسفل الشريط العلوي (المكتبة فقط). يُحرَّر هنا، ويُحفَظ في config.json — يبقى بعد التحديث التلقائي."
);
public static string SettingsHealthAdd => T(" Ajouter", " Add", " 添加", " เพิ่ม", " إضافة");
public static string SettingsHealthEdit => T("✎ Éditer", "✎ Edit", "✎ 编辑", "✎ แก้ไข", "✎ تحرير");
public static string SettingsHealthDelete => T("🗑 Supprimer", "🗑 Delete", "🗑 删除", "🗑 ลบ", "🗑 حذف");
public static string SettingsHealthEmpty => T(
"Aucun check configuré. Le bandeau ne s'affichera pas tant qu'aucune entrée n'aura été ajoutée.",
"No checks configured. The banner stays hidden until at least one entry is added.",
"未配置任何检查。在添加至少一项之前,状态栏将保持隐藏。",
"ยังไม่มีการตรวจสอบที่ตั้งค่าไว้ แถบจะถูกซ่อนจนกว่าจะมีการเพิ่มอย่างน้อยหนึ่งรายการ",
"لم يُهيَّأ أي فحص. سيبقى الشريط مخفياً حتى تتم إضافة إدخال واحد على الأقل."
);
public static string SettingsHealthDeleteConfirm(string name) => T(
$"Supprimer le check « {name} » ?",
$"Delete check « {name} »?",
$"删除检查 \"{name}\"",
$"ลบการตรวจสอบ « {name} »?",
$"حذف الفحص « {name} »؟"
);
public static string HealthEditorTitle => T("Éditer le check", "Edit check", "编辑检查", "แก้ไขการตรวจสอบ", "تحرير الفحص");
public static string HealthEditorTitleNew => T("Nouveau check", "New check", "新建检查", "การตรวจสอบใหม่", "فحص جديد");
public static string HealthEditorName => T("NOM", "NAME", "名称", "ชื่อ", "الاسم");
public static string HealthEditorNameHint => T(
"Libellé court affiché dans la pill du bandeau, ex. « SteamVR ».",
"Short label shown in the banner pill, e.g. \"SteamVR\".",
"状态栏胶囊中显示的简短标签,例如 \"SteamVR\"。",
"ชื่อย่อที่แสดงในป้ายของแถบ เช่น \"SteamVR\"",
"تسمية قصيرة تظهر في الشريط، مثل \"SteamVR\"."
);
public static string HealthEditorKind => T("TYPE DE VÉRIFICATION", "CHECK TYPE", "检查类型", "ประเภทการตรวจสอบ", "نوع الفحص");
public static string HealthEditorKindProcess => T("Processus (Windows)", "Process (Windows)", "进程 (Windows)", "โปรเซส (Windows)", "عملية (Windows)");
public static string HealthEditorKindPing => T("Ping (réseau ICMP)", "Ping (ICMP network)", "Ping (ICMP 网络)", "Ping (เครือข่าย ICMP)", "Ping (شبكة ICMP)");
public static string HealthEditorKindVrDevice => T(
"Appareil SteamVR",
"SteamVR device",
"SteamVR 设备",
"อุปกรณ์ SteamVR",
"جهاز SteamVR"
);
public static string HealthEditorTargetVrDevice => T("APPAREIL VR", "VR DEVICE", "VR 设备", "อุปกรณ์ VR", "جهاز VR");
public static string HealthEditorTargetVrDeviceHint => T(
"Phase 1 : détecte la présence de SteamVR et d'un HMD branché (vert si oui, rouge sinon). La batterie + l'info par-appareil arrive en Phase 1.5 via un sous-process séparé pour être robuste aux crashs natifs OpenVR.",
"Phase 1: detects SteamVR running with an HMD plugged in (green if yes, red otherwise). Battery + per-device info coming in Phase 1.5 via a separate child process to be robust against OpenVR native crashes.",
"第 1 阶段:检测 SteamVR 是否运行以及是否插入了 HMD是则绿色否则红色。电池 + 每个设备的信息将在第 1.5 阶段通过单独的子进程提供,以增强对 OpenVR 原生崩溃的鲁棒性。",
"เฟส 1: ตรวจจับว่า SteamVR กำลังทำงานและมี HMD เชื่อมต่ออยู่หรือไม่ (เขียวถ้าใช่ แดงถ้าไม่) ข้อมูลแบตเตอรี่ + ต่ออุปกรณ์จะมาในเฟส 1.5 ผ่านโปรเซสลูกแยกเพื่อความทนทานต่อข้อผิดพลาดเนทีฟ OpenVR",
"المرحلة 1: تكتشف تشغيل SteamVR مع توصيل HMD (أخضر إذا نعم، أحمر خلاف ذلك). البطارية + المعلومات لكل جهاز ستأتي في المرحلة 1.5 عبر عملية فرعية منفصلة لتكون متينة ضد الأعطال الأصلية OpenVR."
);
public static string HealthEditorTargetProcess => T("CIBLE — NOM DE PROCESSUS", "TARGET — PROCESS NAME", "目标 — 进程名", "เป้าหมาย — ชื่อโปรเซส", "الهدف — اسم العملية");
public static string HealthEditorTargetPing => T("CIBLE — IP OU HOSTNAME", "TARGET — IP OR HOSTNAME", "目标 — IP 或主机名", "เป้าหมาย — IP หรือชื่อโฮสต์", "الهدف — IP أو اسم المضيف");
public static string HealthEditorTargetProcessHint => T(
"Nom de l'exécutable sans .exe, ex. « vrserver » ou « HtcConnectionUtility ». Insensible à la casse.",
"Executable name without .exe, e.g. \"vrserver\" or \"HtcConnectionUtility\". Case-insensitive.",
"可执行文件名(不含 .exe例如 \"vrserver\" 或 \"HtcConnectionUtility\"。不区分大小写。",
"ชื่อไฟล์ปฏิบัติการโดยไม่มี .exe เช่น \"vrserver\" หรือ \"HtcConnectionUtility\" ไม่คำนึงถึงตัวพิมพ์",
"اسم الملف التنفيذي بدون .exe، مثل \"vrserver\" أو \"HtcConnectionUtility\". غير حساس لحالة الأحرف."
);
public static string HealthEditorTargetPingHint => T(
"Adresse IP (ex. 192.168.1.42) ou hostname résolvable. Vide = check ignoré (statut Inconnu).",
"IP address (e.g. 192.168.1.42) or resolvable hostname. Empty = check skipped (Unknown status).",
"IP 地址(例如 192.168.1.42)或可解析的主机名。空 = 跳过检查(未知状态)。",
"ที่อยู่ IP (เช่น 192.168.1.42) หรือชื่อโฮสต์ที่แก้ได้ ว่างเปล่า = ข้ามการตรวจสอบ (สถานะไม่ทราบ)",
"عنوان IP (مثل 192.168.1.42) أو اسم مضيف قابل للحل. فارغ = تخطّي الفحص (الحالة غير معروفة)."
);
public static string HealthEditorRefresh => T("INTERVALLE DE RAFRAÎCHISSEMENT", "REFRESH INTERVAL", "刷新间隔", "ช่วงเวลารีเฟรช", "فاصل التحديث");
public static string HealthEditorRefreshHint => T(
"Délai entre 2 vérifications, en millisecondes. Process ~1 ms (1000 OK) ; Ping = round-trip réseau (5000-10000 conseillé). Min 200 ms.",
"Delay between 2 checks, in milliseconds. Process ~1 ms (1000 OK); Ping = network round-trip (5000-10000 recommended). Min 200 ms.",
"两次检查之间的延迟(毫秒)。进程约 1 毫秒1000 即可Ping = 网络往返(建议 5000-10000。最小 200 毫秒。",
"ระยะเวลาระหว่าง 2 การตรวจสอบเป็นมิลลิวินาที โปรเซส ~1 ms (1000 OK); Ping = round-trip เครือข่าย (แนะนำ 5000-10000). ขั้นต่ำ 200 ms.",
"التأخير بين فحصين بالمللي ثانية. عملية ~1 ms (1000 جيدة)؛ Ping = ذهاب وعودة الشبكة (يوصى 5000-10000). الحد الأدنى 200 ms."
);
public static string HealthEditorIcon => T("PICTOGRAMME", "ICON", "图标", "ไอคอน", "أيقونة");
public static string HealthEditorIconHint => T(
"Affiché à gauche du nom dans la pill. Choisis-en un dans la liste.",
"Displayed to the left of the name in the pill. Pick one from the list.",
"显示在胶囊中名称的左侧。从列表中选择一个。",
"แสดงทางซ้ายของชื่อในป้าย เลือกหนึ่งจากรายการ",
"تُعرض على يسار الاسم في الشارة. اختر واحدةً من القائمة."
);
public static string HealthEditorPingAdvanced => T(
"Avancé — seuils Ping",
"Advanced — Ping thresholds",
"高级 — Ping 阈值",
"ขั้นสูง — เกณฑ์ Ping",
"متقدم — حدود Ping"
);
public static string HealthEditorPingWarn => T("Warning ms — RTT au-dessus = orange", "Warning ms — RTT above = amber", "警告 ms — 高于此值 = 橙色", "Warning ms — RTT เกินค่านี้ = ส้ม", "تحذير ms — RTT فوق ذلك = برتقالي");
public static string HealthEditorPingError => T("Error ms — RTT au-dessus = rouge", "Error ms — RTT above = red", "错误 ms — 高于此值 = 红色", "Error ms — RTT เกินค่านี้ = แดง", "خطأ ms — RTT فوق ذلك = أحمر");
public static string HealthEditorPingTimeout => T("Timeout ms — abandon de la requête", "Timeout ms — abort the request", "超时 ms — 中止请求", "Timeout ms — ยกเลิกคำขอ", "مهلة ms — إلغاء الطلب");
public static string HealthEditorErrorName => T(
"Le nom est requis.",
"Name is required.",
"需要名称。",
"ต้องระบุชื่อ",
"الاسم مطلوب."
);
public static string HealthEditorErrorRefresh => T(
"Intervalle invalide. Saisis un entier ≥ 200 (millisecondes).",
"Invalid interval. Enter an integer ≥ 200 (milliseconds).",
"间隔无效。请输入 ≥ 200 的整数(毫秒)。",
"ช่วงเวลาไม่ถูกต้อง ใส่จำนวนเต็ม ≥ 200 (มิลลิวินาที)",
"فاصل غير صالح. أدخل عدداً صحيحاً ≥ 200 (مللي ثانية)."
);
// ==================== COPYRIGHT ====================
public static string Copyright => T(
"© 2026 ASTERION VR — Tous droits réservés",

View File

@@ -0,0 +1,26 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
/// <summary>
/// Cache disque du dernier manifest récupéré avec succès. Sert deux usages :
/// 1. Permet au <c>LanCacheServer</c> de servir le manifest aux peers du LAN
/// (route <c>GET /v1/manifest</c>).
/// 2. Permet au launcher de fallback sur ce cache si OVH est injoignable et
/// qu'aucun peer LAN ne répond non plus (mode offline complet).
///
/// Stocke le JSON brut (pas un objet désérialisé) pour préserver la signature
/// Ed25519 byte-for-byte — la canonicalisation côté validation exige les bytes
/// originaux. Path : <c>%LocalAppData%\PSLauncher\manifest-cache.json</c>.
/// </summary>
public interface IManifestCache
{
/// <summary>Persiste le JSON brut sur disque (atomic via .tmp + Move).</summary>
Task SaveAsync(string rawJson, CancellationToken ct);
/// <summary>Chemin du fichier cache (utilisé par LanCacheServer pour servir /v1/manifest).</summary>
string GetCachedFilePath();
/// <summary>Lit + désérialise le manifest cached. Null si pas de cache.</summary>
Task<RemoteManifest?> TryLoadAsync(CancellationToken ct);
}

View File

@@ -2,8 +2,26 @@ using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
/// <summary>D'où provient le dernier manifest récupéré avec succès.</summary>
public enum ManifestSource
{
/// <summary>Pas encore récupéré dans cette session.</summary>
None,
/// <summary>Source canonique : serveur OVH via Internet.</summary>
Ovh,
/// <summary>Peer LAN (offline ou OVH down). Manifest peut être plus ancien que celui d'OVH.</summary>
Peer,
/// <summary>Cache disque local (offline + aucun peer atteignable). Manifest figé à la dernière fetch.</summary>
DiskCache,
}
public interface IManifestService
{
Task<RemoteManifest> FetchAsync(CancellationToken ct);
Task<string> FetchReleaseNotesAsync(string url, CancellationToken ct);
/// <summary>Source du dernier <see cref="FetchAsync"/> réussi. Sert au call site
/// pour décider de skip les fetch dépendants d'OVH (release notes, signed URL)
/// quand on sait qu'on est offline.</summary>
ManifestSource LastSource { get; }
}

View File

@@ -0,0 +1,61 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Configuration;
using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
public sealed class ManifestCache : IManifestCache
{
private const string FileName = "manifest-cache.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly IConfigStore _configStore;
private readonly ILogger<ManifestCache> _logger;
public ManifestCache(IConfigStore configStore, ILogger<ManifestCache> logger)
{
_configStore = configStore;
_logger = logger;
}
public string GetCachedFilePath() => Path.Combine(_configStore.ConfigDirectory, FileName);
public async Task SaveAsync(string rawJson, CancellationToken ct)
{
var path = GetCachedFilePath();
var tmp = path + ".tmp";
try
{
await File.WriteAllTextAsync(tmp, rawJson, Encoding.UTF8, ct).ConfigureAwait(false);
// Atomic swap : si on est interrompu pendant l'écriture, le cache reste valide.
File.Move(tmp, path, overwrite: true);
_logger.LogDebug("Manifest cached to {Path} ({Bytes} bytes)", path, rawJson.Length);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cache manifest to disk (non-fatal)");
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
}
}
public async Task<RemoteManifest?> TryLoadAsync(CancellationToken ct)
{
var path = GetCachedFilePath();
if (!File.Exists(path)) return null;
try
{
var raw = await File.ReadAllTextAsync(path, Encoding.UTF8, ct).ConfigureAwait(false);
return JsonSerializer.Deserialize<RemoteManifest>(raw, JsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read manifest cache from {Path}", path);
return null;
}
}
}

View File

@@ -1,6 +1,10 @@
using System.Net.Http.Json;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using PSLauncher.Core.Lan;
using PSLauncher.Core.Security;
using PSLauncher.Models;
namespace PSLauncher.Core.Manifests;
@@ -14,40 +18,218 @@ public sealed class ManifestService : IManifestService
private readonly HttpClient _http;
private readonly Func<string> _serverBaseUrlProvider;
private readonly Func<string?> _channelProvider;
private readonly IManifestCache _manifestCache;
private readonly IPeerManifestFetcher _peerManifestFetcher;
private readonly ISettingsLockService _settingsLock;
private readonly ILogger<ManifestService> _logger;
public ManifestService(HttpClient http, Func<string> serverBaseUrlProvider, ILogger<ManifestService> logger)
public ManifestSource LastSource { get; private set; } = ManifestSource.None;
public ManifestService(
HttpClient http,
Func<string> serverBaseUrlProvider,
Func<string?> channelProvider,
IManifestCache manifestCache,
IPeerManifestFetcher peerManifestFetcher,
ISettingsLockService settingsLock,
ILogger<ManifestService> logger)
{
_http = http;
_serverBaseUrlProvider = serverBaseUrlProvider;
_channelProvider = channelProvider;
_manifestCache = manifestCache;
_peerManifestFetcher = peerManifestFetcher;
_settingsLock = settingsLock;
_logger = logger;
}
/// <summary>
/// No-op depuis v0.28 : le hash settings-lock vient maintenant de la
/// <c>LicenseValidationResponse</c> (per-license). Voir
/// <see cref="PSLauncher.Core.Licensing.LicenseService.ValidateAsync"/> et
/// <see cref="PSLauncher.Core.Licensing.LicenseService.GetCached"/> pour la
/// propagation effective vers <see cref="ISettingsLockService"/>.
/// </summary>
private void PropagateSettingsLock(RemoteManifest? manifest)
{
_ = manifest;
_ = _settingsLock;
}
/// <summary>
/// Timeout court sur la tentative OVH : si le serveur Internet ne répond pas
/// rapidement, on bascule sur les fallbacks LAN/cache plutôt que d'attendre les
/// 100s du HttpClient.Timeout par défaut. Avec la race OVH+peer, ce timeout
/// devient juste une borne haute — en pratique le peer répond en &lt;200ms si
/// dispo, OVH gagne la course en online normal.
/// </summary>
private const int OvhFetchTimeoutMs = 3_000;
/// <summary>
/// Stratégie : on probe la connectivité Internet d'abord (~10ms si online).
/// - Online → OVH seul (source canonique, garantit de voir la version la plus
/// récente même si nos peers ont un manifest plus ancien). Fallback cache
/// disque si OVH plante.
/// - Offline → peer LAN seul (pas la peine de payer le timeout OVH puisqu'on
/// sait que ça va échouer). Fallback cache disque si pas de peer.
///
/// Le test de connectivité combine ICMP ping (rapide) + TCP connect au serveur
/// (au cas où le firewall corporate bloque ICMP) pour minimiser les faux négatifs.
/// </summary>
public async Task<RemoteManifest> FetchAsync(CancellationToken ct)
{
var url = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
_logger.LogInformation("Fetching manifest: {Url}", url);
var isOnline = await IsOnlineAsync(ct).ConfigureAwait(false);
_logger.LogInformation("Fetching manifest (online={Online})", isOnline);
if (isOnline)
{
// En ligne : OVH canonique, jamais de peer (qui pourrait avoir un manifest
// plus ancien et masquer la dernière version publiée).
try
{
var rawJson = await FetchFromOvhAsync(ct).ConfigureAwait(false);
var manifest = JsonSerializer.Deserialize<RemoteManifest>(rawJson, JsonOptions)
?? throw new InvalidOperationException("Empty manifest");
await _manifestCache.SaveAsync(rawJson, ct).ConfigureAwait(false);
LastSource = ManifestSource.Ovh;
PropagateSettingsLock(manifest);
return manifest;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
_logger.LogWarning("OVH manifest fetch failed despite online connectivity ({Reason}), falling back to disk cache", ex.Message);
var cached = await _manifestCache.TryLoadAsync(ct).ConfigureAwait(false);
if (cached is not null)
{
LastSource = ManifestSource.DiskCache;
PropagateSettingsLock(cached);
return cached;
}
throw;
}
}
// Offline : peer LAN direct, pas d'attente du timeout OVH.
var peerJson = await _peerManifestFetcher.TryFetchRawAsync(ct).ConfigureAwait(false);
if (peerJson is not null)
{
var manifest = JsonSerializer.Deserialize<RemoteManifest>(peerJson, JsonOptions)
?? throw new InvalidOperationException("Empty peer manifest");
_logger.LogInformation("Manifest récupéré via peer LAN (offline)");
// Cache aussi le peer manifest pour servir aux autres clients du LAN.
await _manifestCache.SaveAsync(peerJson, ct).ConfigureAwait(false);
LastSource = ManifestSource.Peer;
PropagateSettingsLock(manifest);
return manifest;
}
// Aucun peer atteignable : on tente le cache disque local
var localCache = await _manifestCache.TryLoadAsync(ct).ConfigureAwait(false);
if (localCache is not null)
{
_logger.LogInformation("Manifest récupéré depuis le cache disque (offline + pas de peer)");
LastSource = ManifestSource.DiskCache;
PropagateSettingsLock(localCache);
return localCache;
}
throw new InvalidOperationException("Manifest unavailable: offline, no LAN peer responded, no local cache.");
}
private async Task<string> FetchFromOvhAsync(CancellationToken ct)
{
// Channel = manifest filter par-license. NULL ou vide = on appelle
// /manifest tout court (= versions.json default). Sinon /manifest?channel=X
// (le serveur charge versions-{X}.json avec fallback sur default si fichier absent).
// Whitelist côté client aussi pour éviter des chars exotiques qui casseraient l'URL.
var channel = _channelProvider();
var baseUrl = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
// Query params : ?channel=X (filtrage par channel license) + &multiChannel=1
// (opt-in au comportement v1.0.10+ : le serveur envoie plusieurs entries au
// même numéro de version sans dédup, le client sait afficher chacune sur
// sa row). Toujours envoyé — les serveurs anciens l'ignorent silencieusement.
var qs = new System.Text.StringBuilder("?multiChannel=1");
if (!string.IsNullOrWhiteSpace(channel) && System.Text.RegularExpressions.Regex.IsMatch(channel!, "^[a-z0-9_-]{1,64}$"))
{
qs.Append("&channel=").Append(Uri.EscapeDataString(channel!));
}
var url = baseUrl + qs.ToString();
using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
ovhCts.CancelAfter(OvhFetchTimeoutMs);
using var req = new HttpRequestMessage(HttpMethod.Get, url);
// Bypass des caches intermédiaires — l'utilisateur a explicitement cliqué « Vérifier ».
req.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue
{
NoCache = true,
NoStore = true,
};
req.Headers.Pragma.ParseAdd("no-cache");
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
using var resp = await _http.SendAsync(req, ovhCts.Token).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
var manifest = await resp.Content.ReadFromJsonAsync<RemoteManifest>(JsonOptions, ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Empty manifest");
return manifest;
return await resp.Content.ReadAsStringAsync(ovhCts.Token).ConfigureAwait(false);
}
/// <summary>
/// Détection rapide de connectivité Internet : 1) ping ICMP 8.8.8.8 (DNS Google,
/// 99% reachable depuis n'importe quel LAN), 2) si ping ne marche pas (firewall
/// corporate qui bloque ICMP), TCP connect direct sur le port HTTPS du serveur OVH.
/// Online normal : ~10-30ms (premier ping suffit). Offline : ~1.5s pire cas
/// (les deux probes timeout). Bon compromis vs un OVH HTTP fetch qui prend 3s.
/// </summary>
private async Task<bool> IsOnlineAsync(CancellationToken ct)
{
// Test 1 : ping ICMP (rapide ~10ms si online, fail-fast si offline)
try
{
using var ping = new Ping();
var reply = await ping.SendPingAsync(IPAddress.Parse("8.8.8.8"), TimeSpan.FromMilliseconds(800)).ConfigureAwait(false);
if (reply.Status == IPStatus.Success)
{
_logger.LogDebug("Online: ping 8.8.8.8 OK ({Ms} ms)", reply.RoundtripTime);
return true;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Ping 8.8.8.8 raised exception (firewall blocks ICMP ?)");
}
// Test 2 : TCP connect direct sur le port HTTPS du serveur OVH. Cas du
// firewall corporate qui bloque ICMP mais laisse passer HTTPS.
try
{
if (Uri.TryCreate(_serverBaseUrlProvider(), UriKind.Absolute, out var serverUri))
{
using var tcp = new TcpClient();
using var tcpCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
tcpCts.CancelAfter(TimeSpan.FromMilliseconds(800));
await tcp.ConnectAsync(serverUri.Host, serverUri.Port == -1 ? 443 : serverUri.Port, tcpCts.Token).ConfigureAwait(false);
_logger.LogDebug("Online: TCP connect to {Host}:443 OK", serverUri.Host);
return true;
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
_logger.LogDebug(ex, "TCP connect to server failed");
}
_logger.LogInformation("Offline detected (ping + TCP both failed)");
return false;
}
public async Task<string> FetchReleaseNotesAsync(string url, CancellationToken ct)
{
_logger.LogInformation("Fetching release notes: {Url}", url);
using var resp = await _http.GetAsync(url, ct).ConfigureAwait(false);
// Timeout court : les release notes sont du markdown léger, et si OVH ne
// répond pas, on aime mieux ouvrir le dialog d'install rapidement avec
// "release notes indisponibles" plutôt que de bloquer la UI 100s.
using var rnCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
rnCts.CancelAfter(OvhFetchTimeoutMs);
using var resp = await _http.GetAsync(url, rnCts.Token).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
return await resp.Content.ReadAsStringAsync(rnCts.Token).ConfigureAwait(false);
}
private static string TrimSlash(string s) => s.TrimEnd('/');

View File

@@ -0,0 +1,370 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using MySqlConnector;
using PSLauncher.Models;
namespace PSLauncher.Core.Migrations;
/// <summary>
/// Implémentation MySQL/MariaDB du service de migration.
/// Workflow :
/// <code>
/// 1. CREATE TABLE IF NOT EXISTS _launcher_migrations (filename, applied_at, checksum, duration_ms)
/// 2. SELECT déjà-appliquées
/// 3. Liste {installFolder}/_migrations/*.sql triées alphabétiquement
/// 4. Pour chaque non-appliquée :
/// START TRANSACTION
/// Exécute le script (peut contenir plusieurs statements séparés par ;)
/// INSERT INTO _launcher_migrations
/// COMMIT
/// Si exception → ROLLBACK + abort + reporte l'erreur
/// 5. Pour chaque appliquée dont le checksum a changé : log warning (script modifié après publication)
/// </code>
/// </summary>
public sealed class DatabaseMigrationService : IDatabaseMigrationService
{
private const string TrackingTable = "_launcher_migrations";
private readonly Func<DatabaseConfig> _configProvider;
private readonly Func<string?> _passwordProvider;
private readonly ILogger<DatabaseMigrationService> _logger;
public DatabaseMigrationService(
Func<DatabaseConfig> configProvider,
Func<string?> passwordProvider,
ILogger<DatabaseMigrationService> logger)
{
_configProvider = configProvider;
_passwordProvider = passwordProvider;
_logger = logger;
}
public async Task<string?> TestConnectionAsync(CancellationToken ct)
{
try
{
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
await using var cmd = new MySqlCommand("SELECT 1", conn);
await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
return null;
}
catch (Exception ex)
{
return ex.Message;
}
}
public async Task<MigrationResult> ApplyMigrationsAsync(
string installFolder,
IProgress<MigrationProgress>? progress,
CancellationToken ct)
{
var migrationsDir = Path.Combine(installFolder, "_migrations");
if (!Directory.Exists(migrationsDir))
{
_logger.LogInformation("No _migrations/ directory in {Folder}, skipping DB migration step", installFolder);
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
}
// Liste les .sql, ignore les autres extensions (README, etc.)
var files = Directory.EnumerateFiles(migrationsDir, "*.sql", SearchOption.TopDirectoryOnly)
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal)
.ToList();
if (files.Count == 0)
{
_logger.LogInformation("_migrations/ exists but empty in {Folder}", installFolder);
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
}
// Première barrière : vérifier que la base configurée existe, sinon la
// créer. Sans ça la toute première install d'un PC neuf (base pas
// encore présente côté MySQL) plantait dès `OpenAsync` avec
// "Unknown database 'proserveapi'" et l'install s'arrêtait là.
await EnsureDatabaseExistsAsync(ct).ConfigureAwait(false);
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
var applied = await LoadAppliedAsync(conn, ct).ConfigureAwait(false);
var outcomes = new List<MigrationOutcome>();
for (int i = 0; i < files.Count; i++)
{
ct.ThrowIfCancellationRequested();
var filepath = files[i];
var filename = Path.GetFileName(filepath);
progress?.Report(new MigrationProgress(i, files.Count, filename));
var sql = await File.ReadAllTextAsync(filepath, Encoding.UTF8, ct).ConfigureAwait(false);
var checksum = ComputeSha256(sql);
if (applied.TryGetValue(filename, out var prevChecksum))
{
if (!string.Equals(prevChecksum, checksum, StringComparison.OrdinalIgnoreCase))
{
// Détection d'une modif après-publication : on skip mais on log fort.
_logger.LogWarning(
"Migration {File} was modified after being applied (stored sha256={Stored}, current={Now}). " +
"Ignored to preserve historical truth — write a NEW migration file instead.",
filename, prevChecksum[..12], checksum[..12]);
}
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Skipped, 0, null));
continue;
}
var sw = Stopwatch.StartNew();
await using var tx = await conn.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
await ExecuteScriptAsync(conn, tx, sql, ct).ConfigureAwait(false);
await using var insertCmd = new MySqlCommand(
$"INSERT INTO {TrackingTable} (filename, applied_at, checksum, duration_ms) VALUES (@f, NOW(), @c, @d)",
conn, tx);
insertCmd.Parameters.AddWithValue("@f", filename);
insertCmd.Parameters.AddWithValue("@c", checksum);
insertCmd.Parameters.AddWithValue("@d", sw.ElapsedMilliseconds);
await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
sw.Stop();
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Applied, sw.ElapsedMilliseconds, null));
_logger.LogInformation("Migration {File} applied in {Ms} ms", filename, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
sw.Stop();
try { await tx.RollbackAsync(ct).ConfigureAwait(false); } catch { /* swallow */ }
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Failed, sw.ElapsedMilliseconds, ex.Message));
_logger.LogError(ex, "Migration {File} FAILED after {Ms} ms — DB state rolled back", filename, sw.ElapsedMilliseconds);
progress?.Report(new MigrationProgress(i + 1, files.Count, filename));
return new MigrationResult(outcomes, AllSucceeded: false, FailureMessage: $"{filename} : {ex.Message}");
}
}
progress?.Report(new MigrationProgress(files.Count, files.Count, string.Empty));
return new MigrationResult(outcomes, AllSucceeded: true, FailureMessage: null);
}
public async Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct)
{
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
var list = new List<AppliedMigration>();
await using var cmd = new MySqlCommand(
$"SELECT filename, applied_at, checksum, duration_ms FROM {TrackingTable} ORDER BY id",
conn);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
list.Add(new AppliedMigration(
reader.GetString(0),
DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc),
reader.GetString(2),
reader.GetInt64(3)));
}
return list;
}
// ----- helpers -----
private async Task<MySqlConnection> OpenAsync(CancellationToken ct)
{
var cfg = _configProvider();
var pwd = _passwordProvider() ?? string.Empty;
var csb = new MySqlConnectionStringBuilder
{
Server = cfg.Host,
Port = cfg.Port,
UserID = cfg.User,
Password = pwd,
Database = cfg.Database,
// Désactive le pooling : on ouvre une connexion par opération courte,
// pas besoin de garder un pool ouvert sur la machine du user.
Pooling = false,
ConnectionTimeout = 10,
DefaultCommandTimeout = 600, // 10 min : laisse de la marge pour les grosses migrations
AllowUserVariables = true,
};
var conn = new MySqlConnection(csb.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
return conn;
}
/// <summary>
/// Ouvre une connexion SANS spécifier <c>Database</c> dans la connection
/// string, puis exécute <c>CREATE DATABASE IF NOT EXISTS</c>. Évite l'erreur
/// "Unknown database" à la toute première install d'un PC neuf où l'opérateur
/// n'a pas créé la base à la main.
///
/// L'identifier est quoté avec des backticks pour gérer les noms exotiques
/// (espaces, caractères spéciaux) ; on filtre quand même les backticks
/// pour éviter une injection si la config était trafiquée à la main.
/// </summary>
private async Task EnsureDatabaseExistsAsync(CancellationToken ct)
{
var cfg = _configProvider();
if (string.IsNullOrWhiteSpace(cfg.Database))
{
_logger.LogWarning("Database config has no Database name — cannot ensure-exists");
return;
}
var pwd = _passwordProvider() ?? string.Empty;
var csb = new MySqlConnectionStringBuilder
{
Server = cfg.Host,
Port = cfg.Port,
UserID = cfg.User,
Password = pwd,
// PAS de Database = on se connecte au serveur sans base sélectionnée.
// Le user mysql doit avoir CREATE DATABASE sinon ça échouera ici —
// dans ce cas l'opérateur sait que la base doit être créée à la main.
Pooling = false,
ConnectionTimeout = 10,
DefaultCommandTimeout = 30,
};
// Sanitize l'identifier : MySQL identifiers peuvent contenir n'importe
// quoi entre backticks, mais on rejette les backticks internes par
// sécurité (pas d'injection même si la config est falsifiée).
var safeDbName = cfg.Database.Replace("`", "");
var ddl = $"CREATE DATABASE IF NOT EXISTS `{safeDbName}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
await using var conn = new MySqlConnection(csb.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
await using var cmd = new MySqlCommand(ddl, conn);
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
// affected == 1 → base créée. affected == 0 → existait déjà.
if (affected > 0)
_logger.LogInformation("Created database `{Database}` (did not exist yet)", safeDbName);
else
_logger.LogDebug("Database `{Database}` already exists, no-op", safeDbName);
}
private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
{
const string ddl = $"""
CREATE TABLE IF NOT EXISTS {TrackingTable} (
id INT PRIMARY KEY AUTO_INCREMENT,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at DATETIME NOT NULL,
checksum CHAR(64) NOT NULL,
duration_ms BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""";
await using var cmd = new MySqlCommand(ddl, conn);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
private static async Task<Dictionary<string, string>> LoadAppliedAsync(MySqlConnection conn, CancellationToken ct)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
await using var cmd = new MySqlCommand($"SELECT filename, checksum FROM {TrackingTable}", conn);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
map[reader.GetString(0)] = reader.GetString(1);
return map;
}
/// <summary>
/// MySqlCommand exécute UNE commande à la fois. Pour un fichier .sql contenant
/// plusieurs statements séparés par <c>;</c>, on parse manuellement (en respectant
/// les chaînes / commentaires) et on exécute statement-par-statement dans la
/// même transaction. Évite la dépendance à <c>MySqlScript</c> et permet de
/// supporter <see cref="CancellationToken"/> proprement.
/// </summary>
private static async Task ExecuteScriptAsync(MySqlConnection conn, MySqlTransaction tx, string sql, CancellationToken ct)
{
foreach (var stmt in SplitStatements(sql))
{
ct.ThrowIfCancellationRequested();
var trimmed = stmt.Trim();
if (string.IsNullOrEmpty(trimmed)) continue;
// Skip si juste un commentaire ou une ligne blanche
if (IsAllCommentsOrWhitespace(trimmed)) continue;
await using var cmd = new MySqlCommand(trimmed, conn, tx);
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
}
private static bool IsAllCommentsOrWhitespace(string s)
{
// Heuristique simple : si après strip des commentaires il reste des trucs non-blancs.
var stripped = System.Text.RegularExpressions.Regex.Replace(s, @"(--[^\n]*)|(/\*[\s\S]*?\*/)", "");
return string.IsNullOrWhiteSpace(stripped);
}
/// <summary>
/// Splitter SQL minimaliste : sépare par <c>;</c> en respectant les chaînes
/// (' et "), les identifiants (`...`) et les commentaires (-- ... et /* ... */).
/// Suffisant pour la plupart des fichiers de migration courants ; pour des
/// triggers/procs avec DELIMITER, écrire chaque statement dans un fichier séparé.
/// </summary>
private static IEnumerable<string> SplitStatements(string sql)
{
var sb = new StringBuilder();
int i = 0;
char? quote = null;
bool inLineComment = false;
bool inBlockComment = false;
while (i < sql.Length)
{
char c = sql[i];
char next = i + 1 < sql.Length ? sql[i + 1] : '\0';
if (inLineComment)
{
sb.Append(c);
if (c == '\n') inLineComment = false;
i++;
continue;
}
if (inBlockComment)
{
sb.Append(c);
if (c == '*' && next == '/') { sb.Append(next); i += 2; inBlockComment = false; continue; }
i++;
continue;
}
if (quote is not null)
{
sb.Append(c);
if (c == '\\' && i + 1 < sql.Length) { sb.Append(sql[i + 1]); i += 2; continue; }
if (c == quote) quote = null;
i++;
continue;
}
// hors-quote, hors-commentaire
if (c == '-' && next == '-') { sb.Append(c).Append(next); i += 2; inLineComment = true; continue; }
if (c == '/' && next == '*') { sb.Append(c).Append(next); i += 2; inBlockComment = true; continue; }
if (c == '\'' || c == '"' || c == '`') { quote = c; sb.Append(c); i++; continue; }
if (c == ';')
{
yield return sb.ToString();
sb.Clear();
i++;
continue;
}
sb.Append(c);
i++;
}
if (sb.Length > 0)
{
var tail = sb.ToString().Trim();
if (tail.Length > 0) yield return tail;
}
}
private static string ComputeSha256(string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,36 @@
using System.Security.Cryptography;
using System.Text;
namespace PSLauncher.Core.Migrations;
/// <summary>
/// Chiffre / déchiffre le mot de passe MySQL stocké dans <see cref="PSLauncher.Models.DatabaseConfig.EncryptedPassword"/>
/// via DPAPI scope CurrentUser. Même mécanisme que pour la clé de license : un user
/// qui copierait le config.json sur une autre machine ne pourrait pas déchiffrer.
/// </summary>
public static class DatabasePasswordProtector
{
public static string? Protect(string? clearPassword)
{
if (string.IsNullOrEmpty(clearPassword)) return null;
var data = Encoding.UTF8.GetBytes(clearPassword);
var protectedBytes = ProtectedData.Protect(data, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
return Convert.ToBase64String(protectedBytes);
}
public static string? Unprotect(string? base64)
{
if (string.IsNullOrEmpty(base64)) return null;
try
{
var protectedBytes = Convert.FromBase64String(base64);
var data = ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(data);
}
catch
{
// Cache déplacé sur autre machine / autre user → non déchiffrable
return null;
}
}
}

View File

@@ -0,0 +1,57 @@
namespace PSLauncher.Core.Migrations;
/// <summary>
/// Applique les scripts SQL livrés dans le sous-répertoire <c>_migrations/</c>
/// d'un build PROSERVE installé. Chaque script tournant en transaction, le
/// résultat est tracké dans une table <c>_launcher_migrations</c> pour qu'un
/// même script ne soit pas rejoué deux fois (mêmes garanties que Flyway/Doctrine).
/// </summary>
public interface IDatabaseMigrationService
{
/// <summary>
/// Cherche <c>{installFolder}/_migrations/*.sql</c>, applique en ordre alphabétique
/// les scripts pas encore présents dans <c>_launcher_migrations</c>, et reporte la
/// progression. Retourne la liste détaillée des opérations (appliquées + skippées).
/// </summary>
Task<MigrationResult> ApplyMigrationsAsync(
string installFolder,
IProgress<MigrationProgress>? progress,
CancellationToken ct);
/// <summary>
/// Liste les migrations déjà appliquées en base. Utile pour la vue Settings.
/// </summary>
Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct);
/// <summary>
/// Test de connexion. Retourne null si OK, sinon le message d'erreur formaté.
/// </summary>
Task<string?> TestConnectionAsync(CancellationToken ct);
}
public sealed record MigrationProgress(int Done, int Total, string CurrentFilename);
public sealed record MigrationResult(
IReadOnlyList<MigrationOutcome> Outcomes,
bool AllSucceeded,
string? FailureMessage)
{
public int AppliedCount => Outcomes.Count(o => o.Status == MigrationStatus.Applied);
public int SkippedCount => Outcomes.Count(o => o.Status == MigrationStatus.Skipped);
public int FailedCount => Outcomes.Count(o => o.Status == MigrationStatus.Failed);
public bool HasNoMigrationsFolder => !Outcomes.Any() && AllSucceeded;
}
public sealed record MigrationOutcome(
string Filename,
MigrationStatus Status,
long DurationMs,
string? Error);
public enum MigrationStatus { Skipped, Applied, Failed }
public sealed record AppliedMigration(
string Filename,
DateTime AppliedAt,
string Checksum,
long DurationMs);

View File

@@ -5,13 +5,18 @@
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<!-- unsafe requis pour le déréférencement de la vtable OpenVR (Health/OpenVR/OpenVRNative.cs) -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageReference Include="Polly" Version="8.4.2" />
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
<!-- MySQL client : MySqlConnector (BSD-3-Clause), pas Oracle MySql.Data (GPL) -->
<PackageReference Include="MySqlConnector" Version="2.3.7" />
</ItemGroup>
<ItemGroup>

View File

@@ -20,14 +20,32 @@ public sealed class ProcessLauncher : IProcessLauncher
{
FileName = version.ExecutablePath,
WorkingDirectory = version.FolderPath,
UseShellExecute = true
UseShellExecute = true,
};
if (args is not null)
// Construction MANUELLE de la ligne de commande (via Arguments string),
// PAS via psi.ArgumentList. Pourquoi : avec UseShellExecute=true, .NET
// re-sérialise ArgumentList en interne en appliquant un quotage Win32
// auto qui peut insérer des guillemets inattendus autour des args
// contenant `=` (cf. bug rapporté : `-login=Jerome` arrivait à Unreal
// sous une forme que FParse::Value ne reconnaissait pas).
//
// En passant Arguments directement, on contrôle au caractère près
// la ligne de commande effective — c'est la même chaîne qu'aurait
// produite un .bat avec les mêmes paramètres. Le caller (typiquement
// `MainViewModel.BuildAutoModeCliArgs`) est responsable du quotage
// approprié des valeurs avec espaces.
if (args is not null && args.Length > 0)
{
foreach (var arg in args) psi.ArgumentList.Add(arg);
psi.Arguments = string.Join(" ", args);
_logger.LogInformation("Launching {Exe} with args: {Args}",
version.ExecutablePath, psi.Arguments);
}
else
{
_logger.LogInformation("Launching {Exe}", version.ExecutablePath);
}
_logger.LogInformation("Launching {Exe}", version.ExecutablePath);
return SysProcess.Start(psi)
?? throw new InvalidOperationException("Process.Start returned null");
}

View File

@@ -0,0 +1,68 @@
namespace PSLauncher.Core.ReportTool;
/// <summary>
/// Déploie l'outil Report (page web hébergée dans XAMPP) depuis le sous-dossier
/// <c>_report/</c> bundled dans chaque ZIP PROSERVE vers le htdocs local.
/// Conserve N backups horodatés permettant un revert manuel via la UI Settings.
/// </summary>
public interface IReportToolDeployer
{
/// <summary>
/// Si <c>{installFolder}/_report/</c> existe, le copie vers
/// <c>{HtdocsRoot}/{FolderName}/</c> via un rename atomique :
/// <list type="number">
/// <item>copie récursive vers <c>{target}.new/</c></item>
/// <item>rename de l'existant vers <c>{target}.backup-{yyyyMMdd-HHmmss}/</c></item>
/// <item>rename <c>.new</c> → final</item>
/// <item>prune des vieux backups au-delà de <c>MaxBackups</c></item>
/// </list>
/// </summary>
Task<DeployResult> DeployAsync(
string installFolder,
IProgress<DeployProgress>? progress,
CancellationToken ct);
/// <summary>
/// Liste les backups horodatés disponibles dans <c>{HtdocsRoot}</c>, du plus
/// récent au plus ancien. Vide si aucun backup ou si htdocs introuvable.
/// </summary>
Task<IReadOnlyList<BackupInfo>> ListBackupsAsync(CancellationToken ct);
/// <summary>
/// Revient à un backup horodaté précis. Swap atomique : la version actuelle
/// est elle-même renommée en backup-{now} (donc le revert est lui-même
/// reversible), puis le backup choisi est renommé en version active.
/// </summary>
Task<DeployResult> RevertAsync(string backupFolderName, CancellationToken ct);
}
public enum DeployStatus
{
/// <summary>Le ZIP n'avait pas de sous-dossier <c>_report/</c>, rien à faire.</summary>
SkippedNoSourceFolder,
/// <summary>HtdocsRoot configuré n'existe pas (XAMPP pas installé là).</summary>
XamppNotFound,
/// <summary>Erreur I/O pendant la copie ou le rename.</summary>
Failed,
/// <summary>Déploiement (ou revert) réussi.</summary>
Deployed,
/// <summary>Revert demandé sur un backup qui n'existe pas / plus.</summary>
BackupNotFound,
}
public sealed record DeployResult(
DeployStatus Status,
int FilesDeployed,
long BytesDeployed,
string TargetPath,
string? ErrorMessage = null);
public sealed record DeployProgress(int FilesDone, int FilesTotal, string CurrentFilename);
/// <summary>
/// Métadonnées d'un backup horodaté présent dans <c>{HtdocsRoot}</c>.
/// </summary>
public sealed record BackupInfo(
string FolderName, // ProserveReport.backup-20260503-143022
DateTime TimestampUtc, // parsé depuis le suffixe du nom de dossier
long SizeBytes);

View File

@@ -0,0 +1,247 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using PSLauncher.Models;
namespace PSLauncher.Core.ReportTool;
/// <summary>
/// Implémentation : copy récursif + atomic rename + backups horodatés.
/// L'atomicité du rename garantit qu'Apache (qui sert les fichiers en parallèle)
/// ne tombera jamais sur un état mi-déployé. Au pire il sert l'ancienne version
/// pendant 1ms le temps du rename, puis sert la nouvelle.
///
/// Backups : à chaque deploy, l'ancien dossier est conservé sous le nom
/// <c>{FolderName}.backup-yyyyMMdd-HHmmss</c>. On garde les <c>MaxBackups</c>
/// plus récents et on supprime les autres en best-effort. Permet le revert
/// manuel via Settings → Avancés → Outil Report.
/// </summary>
public sealed class ReportToolDeployer : IReportToolDeployer
{
private const string SourceSubdir = "_report";
private const string BackupSuffix = ".backup-";
// Format trié-par-nom = trié-par-date (lexicographique == chronologique)
private const string TimestampFormat = "yyyyMMdd-HHmmss";
private readonly Func<ReportToolConfig> _configProvider;
private readonly ILogger<ReportToolDeployer> _logger;
public ReportToolDeployer(
Func<ReportToolConfig> configProvider,
ILogger<ReportToolDeployer> 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 report deploy", SourceSubdir, installFolder);
return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target);
}
if (!Directory.Exists(cfg.HtdocsRoot))
{
// Crée le dossier htdocs même si XAMPP n'est pas (encore) installé.
// Cas typique : PC fraichement déployé où XAMPP sera installé après.
// L'install PROSERVE peut tout de même s'exécuter — le déploiement
// produira un dossier prêt à servir dès qu'Apache sera up.
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
{
// Nettoyage d'un éventuel résidu de tentative précédente (le .new orphelin)
if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true);
// Copy avec progress
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);
// Atomic swap : current → backup-{ts}, .new → current
if (Directory.Exists(target))
{
Directory.Move(target, backupTarget);
}
Directory.Move(stagingTarget, target);
// Prune des vieux backups (best-effort, ne bloque pas le retour)
PruneOldBackups(cfg);
_logger.LogInformation("Report 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 report 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; // dossier non conforme, on ignore
}
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));
}
// Plus récent en premier
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.");
}
// Le revert lui-même crée un backup de l'état actuel, pour qu'on puisse
// revert le revert si besoin.
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);
// Compte les fichiers du backup restauré pour le retour
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 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, "Revert to {Backup} failed", backupFolderName);
return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message);
}
}
/// <summary>
/// Garde les <c>MaxBackups</c> plus récents, supprime les autres en best-effort.
/// Si <c>MaxBackups = 0</c>, supprime tous les backups (mode "no history").
/// </summary>
private void PruneOldBackups(ReportToolConfig 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) })
// Tri descending par nom = descending par date (yyyyMMdd-HHmmss est lexicographique)
.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 report backup {Dir}", dirs[i].Name);
}
catch (Exception ex)
{
// Apache peut tenir un handle sur un fichier (logs ouverts, etc.).
// On retentera au prochain deploy. Pas bloquant.
_logger.LogDebug(ex, "Could not prune backup {Dir} (will retry next deploy)", dirs[i].Name);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Backup pruning skipped");
}
}
}

View File

@@ -0,0 +1,30 @@
namespace PSLauncher.Core.Security;
/// <summary>
/// Gestion du verrouillage par mot de passe de la section Avancés des Settings.
///
/// Le hash du password vient du manifest signé (<see cref="PSLauncher.Models.RemoteManifest.SettingsLockPasswordHash"/>) :
/// administré centralement depuis le backoffice, intégrité garantie par la signature
/// Ed25519. Quand le hash est non-null/non-vide, les paramètres avancés sont gated
/// derrière un dialog de saisie de password.
///
/// L'unlock est valable pour la session courante du launcher uniquement
/// (pas persisté sur disque). Au redémarrage, l'utilisateur doit re-saisir.
/// </summary>
public interface ISettingsLockService
{
/// <summary>True s'il y a un hash défini dans le manifest courant ET qu'il n'a pas été déverrouillé cette session.</summary>
bool IsLocked { get; }
/// <summary>True s'il y a un hash défini (lock configuré). Si false, pas de lock, accès libre.</summary>
bool HasLockConfigured { get; }
/// <summary>Tente de déverrouiller avec un password. True si match (déverrouillage actif), false sinon.</summary>
bool TryUnlock(string password);
/// <summary>Force le re-verrouillage (par exemple à la fermeture du dialog Settings).</summary>
void Lock();
/// <summary>Met à jour le hash du password (appelé après chaque fetch manifest réussi).</summary>
void SetPasswordHash(string? hash);
}

View File

@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
namespace PSLauncher.Core.Security;
public sealed class SettingsLockService : ISettingsLockService
{
private string? _expectedHashHex;
private bool _unlocked;
public bool HasLockConfigured =>
!string.IsNullOrWhiteSpace(_expectedHashHex);
public bool IsLocked => HasLockConfigured && !_unlocked;
public bool TryUnlock(string password)
{
if (!HasLockConfigured)
{
_unlocked = true;
return true;
}
if (string.IsNullOrEmpty(password)) return false;
var inputHash = HashHex(password);
var match = string.Equals(inputHash, _expectedHashHex, StringComparison.OrdinalIgnoreCase);
if (match) _unlocked = true;
return match;
}
public void Lock() => _unlocked = false;
public void SetPasswordHash(string? hash)
{
_expectedHashHex = string.IsNullOrWhiteSpace(hash) ? null : hash.Trim();
// Si le hash change (admin a modifié le password backoffice), on re-verrouille.
_unlocked = false;
}
/// <summary>SHA-256 hex lowercase d'une string UTF-8. Format attendu par le backoffice.</summary>
public static string HashHex(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
var sb = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}

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