Compare commits

..

45 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
322 changed files with 51307 additions and 462 deletions

View File

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

View File

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

Binary file not shown.

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

231
server/admin/channels.php Normal file
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') { if ($action === 'set_launcher') {
$lver = trim($_POST['launcher_version'] ?? ''); $lver = trim($_POST['launcher_version'] ?? '');
$lmin = trim($_POST['launcher_min_required'] ?? ''); $lmin = trim($_POST['launcher_min_required'] ?? '');
if (!preg_match('/^\d+\.\d+\.\d+$/', $lver)) { // X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test interne).
throw new Exception('Numéro de version launcher invalide (X.Y.Z attendu).'); if (!preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lver)) {
throw new Exception('Numéro de version launcher invalide (X.Y.Z ou X.Y.Z.B attendu).');
} }
if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+$/', $lmin)) { if ($lmin !== '' && !preg_match('/^\d+\.\d+\.\d+(\.\d+)?$/', $lmin)) {
throw new Exception('Numéro de version "minRequired" invalide.'); throw new Exception('Numéro de version "minRequired" invalide.');
} }
$base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher'; $base = $config['base_url'] ?? 'https://asterionvr.com/PS_Launcher';
@@ -76,6 +77,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$message = "Sortie du script (scope: launcher{$forceLabel}) :\n" . implode("\n", $result['log']); $message = "Sortie du script (scope: launcher{$forceLabel}) :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error'; if (!$result['ok']) $messageType = 'error';
} }
// (Le settings lock est maintenant par-license, géré dans licenses.php — voir migration 003.)
} catch (Exception $e) { } catch (Exception $e) {
$message = $e->getMessage(); $message = $e->getMessage();
$messageType = 'error'; $messageType = 'error';
@@ -144,14 +146,14 @@ Layout::header('Launcher', 'launcher');
<input type="hidden" name="action" value="set_launcher"> <input type="hidden" name="action" value="set_launcher">
<div class="row"> <div class="row">
<div class="col field"> <div class="col field">
<label>Version launcher (X.Y.Z)</label> <label>Version launcher (X.Y.Z ou X.Y.Z.B)</label>
<input type="text" name="launcher_version" placeholder="0.6.0" <input type="text" name="launcher_version" placeholder="0.6.0"
value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+"> value="<?= htmlspecialchars($launcher['version'] ?? '') ?>" required pattern="\d+\.\d+\.\d+(\.\d+)?">
</div> </div>
<div class="col field"> <div class="col field">
<label>Version minimale requise (X.Y.Z)</label> <label>Version minimale requise (X.Y.Z ou X.Y.Z.B)</label>
<input type="text" name="launcher_min_required" placeholder="0.5.0" <input type="text" name="launcher_min_required" placeholder="0.5.0"
value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+"> value="<?= htmlspecialchars($launcher['minRequired'] ?? '0.5.0') ?>" pattern="\d+\.\d+\.\d+(\.\d+)?">
</div> </div>
</div> </div>
<button class="btn btn-success" type="submit">Définir</button> <button class="btn btn-success" type="submit">Définir</button>

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'], 'dashboard' => ['index.php', 'Dashboard'],
'licenses' => ['licenses.php', 'Licenses'], 'licenses' => ['licenses.php', 'Licenses'],
'versions' => ['versions.php', 'Versions'], 'versions' => ['versions.php', 'Versions'],
'channels' => ['channels.php', 'Channels'],
'launcher' => ['launcher.php', 'Launcher'], 'launcher' => ['launcher.php', 'Launcher'],
'audit' => ['audit.php', 'Audit'], 'audit' => ['audit.php', 'Audit'],
]; ];

201
server/admin/lib/Mailer.php Normal file
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); declare(strict_types=1);
require __DIR__ . '/lib/Auth.php'; require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/Layout.php'; require __DIR__ . '/lib/Layout.php';
require __DIR__ . '/lib/Channels.php';
require __DIR__ . '/lib/Mailer.php';
require __DIR__ . '/../api/lib/Db.php'; require __DIR__ . '/../api/lib/Db.php';
use PSLauncher\Admin\Auth; use PSLauncher\Admin\Auth;
use PSLauncher\Admin\Layout; use PSLauncher\Admin\Layout;
use PSLauncher\Admin\Channels;
use PSLauncher\Admin\Mailer;
use PSLauncher\Db; use PSLauncher\Db;
Auth::requireLogin(); Auth::requireLogin();
@@ -27,6 +31,25 @@ function generateLicenseKey(): string
return 'PRSRV-' . implode('-', $groups); return 'PRSRV-' . implode('-', $groups);
} }
/**
* Liste les channels disponibles depuis le registre channels.json. Le channel
* « default » apparaît avec une clé vide (= NULL en DB → comportement legacy
* « pas de tag, voit que default »).
*/
function listAvailableChannels(): array
{
$manifestDir = dirname(__DIR__) . '/manifest';
$registry = Channels::load($manifestDir);
$out = ['' => '(aucun — voit uniquement « default »)'];
foreach ($registry['channels'] as $c) {
$name = (string)($c['name'] ?? '');
if ($name === '' || $name === 'default') continue; // default est l'option implicite (clé vide)
$label = $c['label'] ?? $name;
$out[$name] = "{$name}{$label}";
}
return $out;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
Auth::checkCsrf(); Auth::checkCsrf();
$action = $_POST['action'] ?? ''; $action = $_POST['action'] ?? '';
@@ -37,6 +60,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$until = trim($_POST['until'] ?? ''); $until = trim($_POST['until'] ?? '');
$maxMachines = max(1, (int)($_POST['max_machines'] ?? 1)); $maxMachines = max(1, (int)($_POST['max_machines'] ?? 1));
$notes = trim($_POST['notes'] ?? '') ?: null; $notes = trim($_POST['notes'] ?? '') ?: null;
// Channel = '' ↦ NULL en DB (default manifest). Whitelist regex anti-injection.
$channel = trim((string)($_POST['channel'] ?? ''));
if ($channel === '') {
$channel = null;
} elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
throw new Exception('Channel invalide : seules les lettres minuscules, chiffres, _ et - sont autorisés (max 64 caractères).');
}
$canSeeBetas = isset($_POST['can_see_betas']) ? 1 : 0;
if ($owner === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) { if ($owner === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $until)) {
throw new Exception('Nom du client et date d\'expiration sont requis (date au format YYYY-MM-DD).'); throw new Exception('Nom du client et date d\'expiration sont requis (date au format YYYY-MM-DD).');
@@ -45,8 +76,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
for ($attempts = 0; $attempts < 5; $attempts++) { for ($attempts = 0; $attempts < 5; $attempts++) {
$newKey = generateLicenseKey(); $newKey = generateLicenseKey();
try { try {
$db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, notes) VALUES (?, ?, NOW(), ?, ?, ?)') $db->prepare('INSERT INTO licenses (license_key, owner_name, issued_at, download_entitlement_until, max_machines, channel, can_see_betas, notes) VALUES (?, ?, NOW(), ?, ?, ?, ?, ?)')
->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $notes]); ->execute([$newKey, $owner, $until . ' 23:59:59', $maxMachines, $channel, $canSeeBetas, $notes]);
$message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée."; $message = "License émise avec succès. Note la clé maintenant — elle ne sera plus jamais affichée.";
break; break;
} catch (PDOException $e) { } catch (PDOException $e) {
@@ -75,6 +106,98 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
->execute([$newUntil . ' 23:59:59', $id]); ->execute([$newUntil . ' 23:59:59', $id]);
$message = "License #{$id} prolongée jusqu'au {$newUntil}."; $message = "License #{$id} prolongée jusqu'au {$newUntil}.";
} }
elseif ($action === 'set_channel') {
$id = (int)($_POST['id'] ?? 0);
$channel = trim((string)($_POST['channel'] ?? ''));
if ($channel === '') {
$channel = null; // default manifest
} elseif (!preg_match('/^[a-z0-9_-]{1,64}$/', $channel)) {
throw new Exception('Channel invalide.');
}
$db->prepare('UPDATE licenses SET channel = ? WHERE id = ?')->execute([$channel, $id]);
$label = $channel ?? 'default';
$message = "License #{$id} : channel passé à « {$label} ». Le client devra rouvrir le launcher pour prendre en compte le changement (revalidation license).";
}
elseif ($action === 'toggle_betas') {
// Renommé semantically (toggle → set explicit) sans casser le nom
// de l'action POST pour préserver les éventuels liens externes
// existants. La modal envoie maintenant `enabled=1` si checkbox
// cochée, sinon le param est absent (= 0). Plus de flip aveugle.
$id = (int)($_POST['id'] ?? 0);
$enabled = isset($_POST['enabled']) ? 1 : 0;
$db->prepare('UPDATE licenses SET can_see_betas = ? WHERE id = ?')->execute([$enabled, $id]);
$message = $enabled
? "License #{$id} : accès aux versions BÊTA activé."
: "License #{$id} : accès aux versions BÊTA désactivé.";
}
elseif ($action === 'set_max_machines') {
$id = (int)($_POST['id'] ?? 0);
$newMax = (int)($_POST['max_machines'] ?? 0);
if ($id <= 0 || $newMax < 1 || $newMax > 100) {
throw new Exception('Nombre de machines invalide (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') { elseif ($action === 'reset_machines') {
$id = (int)($_POST['id'] ?? 0); $id = (int)($_POST['id'] ?? 0);
$db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]); $db->prepare('DELETE FROM license_machines WHERE license_id = ?')->execute([$id]);
@@ -100,6 +223,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
} }
$availableChannels = listAvailableChannels();
$licenses = $db->query( $licenses = $db->query(
'SELECT l.*, 'SELECT l.*,
(SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count (SELECT COUNT(*) FROM license_machines m WHERE m.license_id = l.id) AS machines_count
@@ -152,6 +277,23 @@ Layout::header('Licenses', 'licenses');
<input type="number" name="max_machines" value="1" min="1" max="100"> <input type="number" name="max_machines" value="1" min="1" max="100">
</div> </div>
</div> </div>
<div class="row">
<div class="col field">
<label>Channel <span class="muted" style="font-weight: normal; font-size: 11px;">(quel manifest sert le client ?)</span></label>
<select name="channel">
<?php foreach ($availableChannels as $value => $label): ?>
<option value="<?= htmlspecialchars($value) ?>"><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col field">
<label style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="can_see_betas" value="1" style="width: auto; margin: 0;">
Accès aux versions BÊTA
</label>
<span class="muted" style="font-size: 11px;">Coche pour les testeurs internes / clients pilotes.</span>
</div>
</div>
<div class="field"> <div class="field">
<label>Notes internes (optionnel)</label> <label>Notes internes (optionnel)</label>
<input type="text" name="notes" placeholder="Contact : jean@acme.com"> <input type="text" name="notes" placeholder="Contact : jean@acme.com">
@@ -169,6 +311,7 @@ Layout::header('Licenses', 'licenses');
<th>Émise</th> <th>Émise</th>
<th>Expire</th> <th>Expire</th>
<th>Machines</th> <th>Machines</th>
<th>Channel / Bêta</th>
<th>Statut</th> <th>Statut</th>
<th style="text-align:right">Actions</th> <th style="text-align:right">Actions</th>
</tr> </tr>
@@ -185,8 +328,21 @@ Layout::header('Licenses', 'licenses');
<td>#<?= $l['id'] ?></td> <td>#<?= $l['id'] ?></td>
<td> <td>
<strong><?= htmlspecialchars($l['owner_name']) ?></strong> <strong><?= htmlspecialchars($l['owner_name']) ?></strong>
<?php // Clé license affichée en monospace + bouton 1-clic
// pour copier dans le presse-papier. Permet à l'admin
// de la transmettre rapidement à un client sans passer
// par phpMyAdmin. Sensibilité OK : la page admin est
// déjà derrière auth + la clé seule ne suffit pas
// (validation serveur HMAC + machine_id requis). ?>
<div style="font-size: 11px; margin-top: 4px; display: flex; align-items: center; gap: 6px;">
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 11px; color: #C5E1A5; word-break: break-all;"><?= htmlspecialchars($l['license_key']) ?></code>
<button type="button" class="copy-btn"
data-copy="<?= htmlspecialchars($l['license_key'], ENT_QUOTES) ?>"
title="Copier la clé license"
style="background: transparent; border: 1px solid var(--border); color: var(--text-secondary); cursor: pointer; padding: 2px 8px; border-radius: 3px; font-size: 11px; line-height: 1;">📋</button>
</div>
<?php if (!empty($l['notes'])): ?> <?php if (!empty($l['notes'])): ?>
<div class="muted" style="font-size: 11px;"><?= htmlspecialchars($l['notes']) ?></div> <div class="muted" style="font-size: 11px; margin-top: 4px;"><?= htmlspecialchars($l['notes']) ?></div>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td> <td class="muted"><?= date('d/m/Y', strtotime($l['issued_at'])) ?></td>
@@ -202,43 +358,288 @@ Layout::header('Licenses', 'licenses');
0 / <?= $l['max_machines'] ?> 0 / <?= $l['max_machines'] ?>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td>
<!-- READ-ONLY : channel actuel + badge BÊTA si actif. Tous
les éditeurs sont passés dans le modal pour éviter
l'encombrement de la cellule. -->
<code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; font-size: 11px;"
title="Channel actuellement attribué">
<?= htmlspecialchars($l['channel'] ?? 'default') ?>
</code>
<?php if ((int)($l['can_see_betas'] ?? 0) === 1): ?>
<span class="badge badge-warning" style="margin-left: 4px;" title="Voit les versions taggées beta">β BÊTA</span>
<?php endif; ?>
<?php $hasLock = !empty($l['settings_lock_password_hash']); ?>
<?php if ($hasLock): ?>
<span class="badge badge-secondary" style="margin-left: 4px;" title="Section Settings → Avancés verrouillée par mot de passe">🔒</span>
<?php endif; ?>
</td>
<td><span class="badge <?= $cls ?>"><?= $status ?></span></td> <td><span class="badge <?= $cls ?>"><?= $status ?></span></td>
<td style="text-align:right; white-space: nowrap;"> <td style="text-align:right; white-space: nowrap;">
<details style="display: inline-block; margin: 0 4px;"> <?php $modalId = 'edit-license-' . (int)$l['id']; ?>
<summary class="btn btn-secondary">Prolonger</summary> <div class="row-actions">
<form method="post" style="margin-top: 8px; display: flex; gap: 4px; align-items: center;"> <button type="button" class="btn btn-primary"
onclick="document.getElementById('<?= $modalId ?>').showModal()">
✎ Modifier
</button>
<?php if (!$isRevoked): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-danger" type="submit">✕ Révoquer</button>
</form>
<?php else: ?>
<form method="post" style="display:inline">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="unrevoke">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<button class="btn btn-secondary" type="submit">↩ Réactiver</button>
</form>
<?php endif; ?>
</div>
</td>
</tr>
<?php
// Modal d'édition (capturé en buffer, flush après </table>).
// Même pattern que versions.php : <dialog> dans <table> est
// foster-parented imprévisiblement → on émet hors de la table.
ob_start();
?>
<dialog id="<?= $modalId ?>" class="edit-modal">
<header>
<h3>License #<?= $l['id'] ?> — <?= htmlspecialchars($l['owner_name']) ?></h3>
<button type="button" class="close-x" aria-label="Fermer"
onclick="this.closest('dialog').close()">×</button>
</header>
<nav class="modal-tabs">
<button type="button" data-tab="prolong" class="active">Prolonger</button>
<button type="button" data-tab="slots">Slots</button>
<button type="button" data-tab="channel">Channel</button>
<button type="button" data-tab="beta">BÊTA</button>
<button type="button" data-tab="lock">Lock</button>
<button type="button" data-tab="contacts">Contacts</button>
<button type="button" data-tab="machines">Machines</button>
</nav>
<div class="modal-body">
<!-- TAB : Prolonger -->
<section data-tab="prolong">
<p class="section-intro">
Repousse la date d'expiration de l'entitlement.
Actuelle : <strong><?= date('d/m/Y', strtotime($l['download_entitlement_until'])) ?></strong>
</p>
<form method="post">
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
<input type="hidden" name="action" value="extend"> <input type="hidden" name="action" value="extend">
<input type="hidden" name="id" value="<?= $l['id'] ?>"> <input type="hidden" name="id" value="<?= $l['id'] ?>">
<input type="date" name="new_until" required style="width: 150px;"> <div class="field">
<button class="btn btn-primary" type="submit">OK</button> <label>Nouvelle date d'expiration</label>
<input type="date" name="new_until" required>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Prolonger</button>
</div>
</form> </form>
</details> </section>
<?php if ($l['machines_count'] > 0): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Libérer TOUTES les machines de cette license ?\n\nUtilise plutôt « Voir machines » ci-dessous pour libérer un slot précis.')"> <!-- TAB : Slots -->
<?= Layout::csrfField() ?> <section data-tab="slots" hidden>
<input type="hidden" name="action" value="reset_machines"> <p class="section-intro">
<input type="hidden" name="id" value="<?= $l['id'] ?>"> Nombre maximum de machines pouvant être activées simultanément.
<button class="btn btn-secondary" type="submit" title="Libère TOUS les slots machines de cette license d'un coup">Libérer toutes</button> Actuellement <strong><?= (int)$l['machines_count'] ?></strong>
</form> active(s) sur <strong><?= (int)$l['max_machines'] ?></strong> autorisée(s).
<?php endif; ?> Pour réduire en-dessous du nombre actif, libère d'abord les slots
<?php if (!$isRevoked): ?> via l'onglet « Machines ».
<form method="post" style="display:inline" onsubmit="return confirm('Révoquer définitivement cette license ?')"> </p>
<?= Layout::csrfField() ?> <form method="post">
<input type="hidden" name="action" value="revoke"> <?= Layout::csrfField() ?>
<input type="hidden" name="id" value="<?= $l['id'] ?>"> <input type="hidden" name="action" value="set_max_machines">
<button class="btn btn-danger" type="submit">Révoquer</button> <input type="hidden" name="id" value="<?= $l['id'] ?>">
</form> <div class="field">
<?php else: ?> <label>Slots max</label>
<form method="post" style="display:inline"> <input type="number" name="max_machines" min="1" max="100"
<?= Layout::csrfField() ?> value="<?= (int)$l['max_machines'] ?>" required>
<input type="hidden" name="action" value="unrevoke"> </div>
<input type="hidden" name="id" value="<?= $l['id'] ?>"> <div class="modal-footer" style="margin: 20px -20px -20px;">
<button class="btn btn-secondary" type="submit">Réactiver</button> <button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
</form> <button type="submit" class="btn btn-primary">Enregistrer Slots</button>
<?php endif; ?> </div>
</td> </form>
</tr> </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 <?php
// Sous-row dépliable : liste des machines avec remove individuel. // Sous-row dépliable : liste des machines avec remove individuel.
@@ -247,7 +648,7 @@ Layout::header('Licenses', 'licenses');
if (!empty($machines)): if (!empty($machines)):
?> ?>
<tr> <tr>
<td colspan="7" style="padding: 0;"> <td colspan="8" style="padding: 0;">
<details id="machines-<?= $l['id'] ?>" style="margin: 0;"> <details id="machines-<?= $l['id'] ?>" style="margin: 0;">
<summary style="display: none;"></summary> <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="background: rgba(0,0,0,0.2); padding: 12px 16px; border-top: 1px solid var(--border);">
@@ -285,6 +686,14 @@ Layout::header('Licenses', 'licenses');
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="text-align:right;"> <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" <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.')"> onsubmit="return confirm('Libérer cette machine de la license ?\n\nLe slot redeviendra disponible pour une autre activation.')">
<?= Layout::csrfField() ?> <?= Layout::csrfField() ?>
@@ -313,4 +722,70 @@ Layout::header('Licenses', 'licenses');
</tbody> </tbody>
</table> </table>
</div> </div>
<?php // Dialogs émis hors de la <table> pour que showModal() fonctionne. ?>
<?= $GLOBALS['__editDialogs'] ?? '' ?>
<!-- Handler global pour les onglets des modals (délégation sur document). -->
<script>
(function () {
document.addEventListener('click', function (e) {
// 1. Tabs des modals d'édition license
var btn = e.target.closest('.modal-tabs button[data-tab]');
if (btn) {
var tabName = btn.dataset.tab;
var modal = btn.closest('dialog');
if (!modal) return;
modal.querySelectorAll('.modal-tabs button').forEach(function (b) {
b.classList.toggle('active', b === btn);
});
modal.querySelectorAll('.modal-body > section[data-tab]').forEach(function (s) {
s.hidden = s.dataset.tab !== tabName;
});
return;
}
// 2. Boutons "📋 Copier" (clé license) — copie data-copy via Clipboard API
// + feedback visuel temporaire (1.5 s) sur le bouton lui-même.
var copyBtn = e.target.closest('[data-copy]');
if (copyBtn) {
var text = copyBtn.dataset.copy || '';
var original = copyBtn.textContent;
// Clipboard API moderne (Chrome 66+, Firefox 63+, Edge 79+, Safari 13.1+).
// Fallback execCommand pour les browsers très anciens.
var copyOk = function () {
copyBtn.textContent = '✓';
copyBtn.style.color = '#16A34A';
setTimeout(function () {
copyBtn.textContent = original;
copyBtn.style.color = '';
}, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(copyOk, function () {
// Échec d'autorisation (rare en HTTPS sur même origine) — fallback
fallbackCopy(text, copyOk);
});
} else {
fallbackCopy(text, copyOk);
}
}
});
// Fallback copie via textarea hidden + execCommand. Marche dans tous les
// browsers sans avoir besoin de la permission Clipboard API.
function fallbackCopy(text, onSuccess) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onSuccess(); }
catch (e) { /* silent */ }
document.body.removeChild(ta);
}
})();
</script>
<?php Layout::footer(); <?php Layout::footer();

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -19,7 +19,8 @@ final class DownloadUrl
{ {
public static function handle(array $config, string $version): void public static function handle(array $config, string $version): void
{ {
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+$/', $version)) { // Format X.Y.Z (release publique) ou X.Y.Z.B (itération dev/test).
if (!preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$/', $version)) {
Response::error('invalid_version', 'Version invalide', 400); Response::error('invalid_version', 'Version invalide', 400);
} }
@@ -74,12 +75,36 @@ final class DownloadUrl
Response::error('manifest_missing', 'Manifest absent côté serveur', 500); Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
} }
$manifest = json_decode(file_get_contents($manifestPath), true); $manifest = json_decode(file_get_contents($manifestPath), true);
// Filename attendu, envoyé par le client (extrait de son propre manifest signé).
// Sert à disambiguer les manifestes multi-channels où plusieurs entrées
// partagent le même numéro de version (ex : proserve-firefighter-1.5.4.32 et
// proserve-full-1.5.4.32 sur v1.5.4.32). Sans ce filtre, le foreach ci-dessous
// retournait la 1re entrée matchant le numéro → l'URL signée pointait vers le
// MAUVAIS ZIP, et le client détectait le mismatch filename manifest vs signé
// et abortait (garde-fou côté MainViewModel.InstallVersionAsync). Optionnel
// pour rétro-compat avec les vieux clients (v1.0.4-) qui ne l'envoient pas ;
// dans ce cas la 1re entrée gagne, comme avant.
$expectedFilename = trim((string)($_GET['filename'] ?? ''));
// Whitelist défensive — même règle que la validation du filename lu du manifest
// plus bas dans cette route. Bloque path traversal via query param.
if ($expectedFilename !== '' && !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $expectedFilename)) {
Response::error('invalid_filename', "Query filename invalide : '{$expectedFilename}'", 400);
}
$entry = null; $entry = null;
foreach ($manifest['versions'] ?? [] as $v) { foreach ($manifest['versions'] ?? [] as $v) {
if (($v['version'] ?? '') === $version) { $entry = $v; break; } if (($v['version'] ?? '') !== $version) continue;
if ($expectedFilename !== '') {
$entryFilename = basename(parse_url((string)($v['download']['url'] ?? ''), PHP_URL_PATH) ?: '');
if ($entryFilename !== $expectedFilename) continue;
}
$entry = $v;
break;
} }
if (!$entry) { if (!$entry) {
Response::error('version_not_found', "Version {$version} absente du manifest", 404); $suffix = $expectedFilename !== '' ? " (filename attendu : {$expectedFilename})" : '';
Response::error('version_not_found', "Version {$version} absente du manifest{$suffix}", 404);
} }
// Vérif droits téléchargement // Vérif droits téléchargement
@@ -95,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 // Génère l'URL HMAC-signée
// TTL : 6 h. Compromis entre : // TTL : 6 h. Compromis entre :
// - sécurité (limite la fenêtre de replay si une URL fuit) // - 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 // Pour une connexion plus lente, le client sait auto-refresher l'URL
// pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL). // pendant le DL (cf DownloadManager → 403 retry avec nouvelle URL).
$baseUrl = rtrim($config['base_url'], '/'); $baseUrl = rtrim($config['base_url'], '/');
$relPath = '/builds/proserve-' . $version . '.zip'; $relPath = '/builds/' . $filename;
$exp = time() + 21600; // 6 h $exp = time() + 21600; // 6 h
$secret = $config['hmac_secret'] ?? ''; $secret = $config['hmac_secret'] ?? '';
if ($secret === '') { if ($secret === '') {

View File

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

View File

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

View File

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

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,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é, * 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. * 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 * @param array<string, array{size:int,mtime:int,sha256:string}> $cache
* @return array{sha256:string, fromCache:bool, durationMs:int} * @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; $size = filesize($path) ?: 0;
$mtime = filemtime($path) ?: 0; $mtime = filemtime($path) ?: 0;
$key = realpath($path) ?: $path; $key = realpath($path) ?: $path;
if (isset($cache[$key]) if (!$force
&& isset($cache[$key])
&& ($cache[$key]['size'] ?? -1) === $size && ($cache[$key]['size'] ?? -1) === $size
&& ($cache[$key]['mtime'] ?? -1) === $mtime && ($cache[$key]['mtime'] ?? -1) === $mtime
&& !empty($cache[$key]['sha256'])) { && !empty($cache[$key]['sha256'])) {
@@ -73,7 +83,37 @@ final class SignManifest
} }
$start = microtime(true); $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); $duration = (int)((microtime(true) - $start) * 1000);
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha]; $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 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 * @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
* versions[] (les autres restent inchangées). Le scope 'launcher' * 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[]} * @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)) { if (!is_file($this->manifestPath)) {
$this->out("Manifest introuvable : {$this->manifestPath}"); $this->out("Manifest introuvable : {$this->manifestPath}");
return ['ok' => false, 'log' => $this->log]; return ['ok' => false, 'log' => $this->log];
@@ -117,9 +203,9 @@ final class SignManifest
$doVersions = ($scope === 'all' || $scope === 'versions'); $doVersions = ($scope === 'all' || $scope === 'versions');
$doLauncher = ($scope === 'all' || $scope === 'launcher'); $doLauncher = ($scope === 'all' || $scope === 'launcher');
if ($onlyVersion !== null) { if ($onlyVersion !== null || $onlyEntryId !== null) {
// Mode "hash une seule version" : on ne touche pas au launcher, // Mode "hash une seule entrée / version" : on ne touche pas au launcher,
// et on ne hash que la version demandée dans la section versions[]. // et on ne hash que ce qui correspond au filtre demandé dans versions[].
$doLauncher = false; $doLauncher = false;
$doVersions = true; $doVersions = true;
} }
@@ -131,7 +217,15 @@ final class SignManifest
$hashedVersions = []; $hashedVersions = [];
if ($doVersions) foreach ($manifest['versions'] as &$v) { if ($doVersions) foreach ($manifest['versions'] as &$v) {
$version = $v['version'] ?? '?'; $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 continue; // skip silently les autres versions
} }
$url = $v['download']['url'] ?? ''; $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; $cacheChanged = true;
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)"; $note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note"); $this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");
@@ -231,7 +344,7 @@ final class SignManifest
&& ($cache[$lkey]['sha256'] ?? '') === $existingLsha) { && ($cache[$lkey]['sha256'] ?? '') === $existingLsha) {
$this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit"); $this->out(" [launcher] v{$lver} : " . basename($lpath) . " ({$lsize} octets) — cache hit");
} else { } else {
$r = $this->getOrComputeSha256($lpath, $cache); $r = $this->getOrComputeSha256($lpath, $cache, $force);
$cacheChanged = true; $cacheChanged = true;
$launcher['download']['sha256'] = $r['sha256']; $launcher['download']['sha256'] = $r['sha256'];
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)"; $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) // Découpe naïve sur les ';' suivis d'un saut de ligne (suffit pour notre schéma)
$statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: [])); $statements = array_filter(array_map('trim', preg_split('/;\s*\n/', $sql) ?: []));
foreach ($statements as $stmt) { foreach ($statements as $stmt) {
if ($stmt === '' || str_starts_with($stmt, '--')) continue; // Strip les lignes de commentaire SQL (--) du début / intercalées avant
// de checker si le statement est vide. Sinon : un fichier migration qui
// commence par un header de commentaires se retrouve dans le PREMIER
// chunk avec son premier ALTER (séparés par \n pas par ;\n) ; le chunk
// commence donc par `--`, le check str_starts_with le déclare vide,
// et l'ALTER est silencieusement skip. Bug rapporté : migrations 003+
// qui n'ont qu'un seul ALTER après un bloc de commentaires en tête
// n'étaient jamais appliquées (output vide entre "--- 003 ---" et "===").
$lines = preg_split('/\r?\n/', $stmt) ?: [];
$kept = [];
foreach ($lines as $line) {
$t = ltrim($line);
if ($t === '' || str_starts_with($t, '--')) continue;
$kept[] = $line;
}
$stmt = trim(implode("\n", $kept));
if ($stmt === '') continue;
try { try {
$pdo->exec($stmt); $pdo->exec($stmt);
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60); $first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);
out("" . $first40 . ""); out("" . $first40 . "");
} catch (Exception $e) { } catch (Exception $e) {
out(" ✘ ERREUR : " . $e->getMessage()); $msg = $e->getMessage();
// Tolérance idempotence : on n'aboie pas sur les ALTER qui re-touchent
// une colonne / un index existant. Permet de rejouer migrate.php sans
// risque sur une DB déjà partiellement / complètement migrée.
$idempotent = str_contains($msg, 'Duplicate column')
|| str_contains($msg, 'Duplicate key name')
|| str_contains($msg, "Can't DROP") // ALTER DROP COLUMN inexistante
|| str_contains($msg, 'check that column/key exists');
if ($idempotent) {
$first40 = substr(preg_replace('/\s+/', ' ', $stmt), 0, 60);
out(" ↺ (déjà appliqué) " . $first40 . "");
continue;
}
out(" ✘ ERREUR : " . $msg);
out(" Sur : " . substr($stmt, 0, 80) . ""); out(" Sur : " . substr($stmt, 0, 80) . "");
exit(3); exit(3);
} }

View File

@@ -16,12 +16,16 @@ using PSLauncher.Core.Integrity;
using PSLauncher.Core.Licensing; using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization; using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests; using PSLauncher.Core.Manifests;
using PSLauncher.Core.Security;
using PSLauncher.Core.ApiTool;
using PSLauncher.Core.DocTool; using PSLauncher.Core.DocTool;
using PSLauncher.Core.Lan;
using PSLauncher.Core.Health; using PSLauncher.Core.Health;
using PSLauncher.Core.Health.OpenVRInterop; using PSLauncher.Core.Health.OpenVRInterop;
using PSLauncher.Core.Migrations; using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process; using PSLauncher.Core.Process;
using PSLauncher.Core.ReportTool; using PSLauncher.Core.ReportTool;
using PSLauncher.Core.SteamVr;
using PSLauncher.Core.Updates; using PSLauncher.Core.Updates;
using PSLauncher.Models; using PSLauncher.Models;
using Serilog; using Serilog;
@@ -31,6 +35,13 @@ namespace PSLauncher.App;
public partial class App : Application public partial class App : Application
{ {
private IHost? _host; private IHost? _host;
/// <summary>
/// IServiceProvider du host DI. Exposé pour les rares cas où une vue est
/// instanciée par <c>new</c> hors du container (ex: SettingsDialog) et a
/// besoin de résoudre un service.
/// </summary>
public IServiceProvider? HostServices => _host?.Services;
private static System.Threading.Mutex? _singleInstanceMutex; private static System.Threading.Mutex? _singleInstanceMutex;
private const string SingleInstanceMutexName = "Global\\PSLauncher-3F8E2C1A-9B47-4D5E-A0F8-2E9D1B6C7A3F"; private const string SingleInstanceMutexName = "Global\\PSLauncher-3F8E2C1A-9B47-4D5E-A0F8-2E9D1B6C7A3F";
@@ -69,6 +80,19 @@ public partial class App : Application
"PSLauncher", "logs"); "PSLauncher", "logs");
Directory.CreateDirectory(LogsDirectory); Directory.CreateDirectory(LogsDirectory);
// WebView2 a besoin d'écrire son UserDataFolder (cache, cookies, GPU shader cache,
// etc.). Par défaut il essaie à côté du .exe — ce qui échoue silencieusement quand
// on est installé en Program Files (read-only sans admin) → onglets Report/Doc
// vides. On force le user-data-folder dans %LocalAppData%\PSLauncher\WebView2 via
// la var d'environnement officielle, lue par WebView2 à la première initialisation.
// À setter AVANT que tout contrôle WebView2 ne soit instancié → ici, dans OnStartup,
// est largement assez tôt (la MainWindow n'est créée que plus bas).
var webViewDataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "WebView2");
Directory.CreateDirectory(webViewDataDir);
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", webViewDataDir, EnvironmentVariableTarget.Process);
// Bootstrap i18n : on lit d'abord la config (sans services DI) pour fixer la // Bootstrap i18n : on lit d'abord la config (sans services DI) pour fixer la
// langue avant que le moindre élément XAML soit instancié. // langue avant que le moindre élément XAML soit instancié.
try try
@@ -96,6 +120,19 @@ public partial class App : Application
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory); Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
// RENDER MODE : sur certains environnements (RDP, VMs sans hardware accel,
// GPU émulé, Intel HD anciens, Windows Server sans Desktop Experience…)
// l'accélération matérielle WPF plante en silence : la fenêtre s'affiche
// toute blanche, aucun contenu ne rend, aucune exception ne remonte. C'est
// un bug classique non rattrapable côté .NET parce que le pipeline graphique
// tombe entre les mailles du try/catch. On force le rendu software dans 3 cas :
// 1. RenderCapability.Tier == 0 (WPF lui-même détecte aucune accel possible)
// 2. Session Bureau à distance (RDP) détectée
// 3. Var d'env PSLAUNCHER_SOFTWARE_RENDER=1 (override manuel pour debug)
// Trade-off : rendu software plus lent (~30% impact sur le scrolling de la
// liste des versions), mais au moins ça affiche QUELQUE CHOSE.
ApplyRenderModeFallback();
AppDomain.CurrentDomain.UnhandledException += (_, args) => AppDomain.CurrentDomain.UnhandledException += (_, args) =>
{ {
// CRITIQUE : on flush AVANT que le process meure. Sans ça, sur les // CRITIQUE : on flush AVANT que le process meure. Sans ça, sur les
@@ -159,10 +196,20 @@ public partial class App : Application
services.AddSingleton<IZipInstaller, ZipInstaller>(); services.AddSingleton<IZipInstaller, ZipInstaller>();
services.AddSingleton<IDownloadStateStore, DownloadStateStore>(); services.AddSingleton<IDownloadStateStore, DownloadStateStore>();
services.AddSingleton<IManifestCache, ManifestCache>();
services.AddSingleton<ISettingsLockService, SettingsLockService>();
services.AddSingleton<IManifestService>(sp => services.AddSingleton<IManifestService>(sp =>
new ManifestService( new ManifestService(
sp.GetRequiredService<HttpClient>(), sp.GetRequiredService<HttpClient>(),
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl, () => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
// Channel provider : lit le channel depuis la license cachée à
// chaque fetch (sp.GetRequiredService est lazy, donc pas de
// dépendance circulaire avec ILicenseService au démarrage).
// Si pas de license cachée → null → manifest default.
() => sp.GetRequiredService<ILicenseService>().GetCached()?.Channel,
sp.GetRequiredService<IManifestCache>(),
sp.GetRequiredService<IPeerManifestFetcher>(),
sp.GetRequiredService<ISettingsLockService>(),
sp.GetRequiredService<ILogger<ManifestService>>())); sp.GetRequiredService<ILogger<ManifestService>>()));
services.AddSingleton<IDownloadManager, DownloadManager>(); services.AddSingleton<IDownloadManager, DownloadManager>();
@@ -175,6 +222,7 @@ public partial class App : Application
() => sp.GetRequiredService<LocalConfig>().ServerBaseUrl, () => sp.GetRequiredService<LocalConfig>().ServerBaseUrl,
sp.GetRequiredService<IConfigStore>(), sp.GetRequiredService<IConfigStore>(),
sp.GetRequiredService<LocalConfig>(), sp.GetRequiredService<LocalConfig>(),
sp.GetRequiredService<ISettingsLockService>(),
sp.GetRequiredService<ILogger<LicenseService>>())); sp.GetRequiredService<ILogger<LicenseService>>()));
services.AddSingleton<IToastService, ToastService>(); services.AddSingleton<IToastService, ToastService>();
@@ -204,6 +252,56 @@ public partial class App : Application
configProvider: () => sp.GetRequiredService<LocalConfig>().DocTool, configProvider: () => sp.GetRequiredService<LocalConfig>().DocTool,
sp.GetRequiredService<ILogger<DocToolDeployer>>())); 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<IOpenVrService, OpenVrService>();
services.AddSingleton<ISystemHealthService, SystemHealthService>(); services.AddSingleton<ISystemHealthService, SystemHealthService>();
@@ -213,6 +311,16 @@ public partial class App : Application
}) })
.Build(); .Build();
// Démarre les hosted services (LanCacheServer, LanDiscoveryService, …).
// Sans ce StartAsync, les BackgroundService.ExecuteAsync ne seraient
// jamais appelés — le DI les construit mais ne les "réveille" pas.
// Fire-and-forget : on ne bloque pas le UI thread, mais on garde l'erreur
// dans le log si un service plante au démarrage.
_ = _host.StartAsync().ContinueWith(t =>
{
if (t.IsFaulted) Log.Error(t.Exception, "Host StartAsync failed");
}, TaskScheduler.Default);
var window = _host.Services.GetRequiredService<MainWindow>(); var window = _host.Services.GetRequiredService<MainWindow>();
window.DataContext = _host.Services.GetRequiredService<MainViewModel>(); window.DataContext = _host.Services.GetRequiredService<MainViewModel>();
// Mise en page droite-à-gauche pour les langues RTL (arabe). // Mise en page droite-à-gauche pour les langues RTL (arabe).
@@ -222,9 +330,67 @@ public partial class App : Application
window.Show(); window.Show();
} }
/// <summary>
/// Décide entre rendu hardware (par défaut WPF) et rendu software, en fonction
/// de l'environnement détecté + override par variable d'environnement. Log
/// la décision pour faciliter le debug si l'utilisateur reporte un écran blanc.
/// </summary>
private static void ApplyRenderModeFallback()
{
try
{
// Override manuel : PSLAUNCHER_SOFTWARE_RENDER=1 force le software.
// Le user peut le set dans les variables d'environnement Windows OU dans
// le raccourci .lnk (champ Cible : "cmd /c set PSLAUNCHER_SOFTWARE_RENDER=1 && start PS_Launcher.exe")
var envOverride = Environment.GetEnvironmentVariable("PSLAUNCHER_SOFTWARE_RENDER");
var manualSoftware = !string.IsNullOrEmpty(envOverride)
&& (envOverride == "1" || envOverride.Equals("true", StringComparison.OrdinalIgnoreCase));
// RenderCapability.Tier renvoie un int packed : (tier << 16). Tier 0 = pas
// d'accélération matérielle possible. Tier 1 = partielle. Tier 2 = complète.
var tier = System.Windows.Media.RenderCapability.Tier >> 16;
// GetSystemMetrics(SM_REMOTESESSION) = 1 si la session est une session RDP.
// SM_REMOTESESSION constant = 0x1000 (4096).
var isRdp = GetSystemMetrics(0x1000) != 0;
var shouldUseSoftware = manualSoftware || tier == 0 || isRdp;
Log.Information("Render env : Tier={Tier} RDP={Rdp} EnvOverride={EnvOverride} → SoftwareOnly={Sw}",
tier, isRdp, manualSoftware, shouldUseSoftware);
if (shouldUseSoftware)
{
System.Windows.Media.RenderOptions.ProcessRenderMode =
System.Windows.Interop.RenderMode.SoftwareOnly;
Log.Warning("WPF render mode forced to SoftwareOnly (cause: " +
(manualSoftware ? "env override" : tier == 0 ? "Tier=0 no GPU accel" : "RDP session") + ")");
}
}
catch (Exception ex)
{
// Pas critique : si le fallback échoue, on continue avec le default
// hardware accel et on espère que ça marche. Le user verra un écran
// blanc si non, mais au moins l'app aura pas crashé.
Log.Warning(ex, "Failed to evaluate render-mode fallback, keeping default");
}
}
[DllImport("user32.dll")]
private static extern int GetSystemMetrics(int nIndex);
protected override void OnExit(ExitEventArgs e) protected override void OnExit(ExitEventArgs e)
{ {
Log.Information("PSLauncher shutting down"); Log.Information("PSLauncher shutting down");
// Stop propre des hosted services (laisse 5s pour qu'ils ferment leurs
// sockets HttpListener / UdpClient sans laisser le port en TIME_WAIT trop
// longtemps). Best-effort : si timeout, on continue le shutdown quand même.
try
{
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
_host?.StopAsync(stopCts.Token).GetAwaiter().GetResult();
}
catch (Exception ex) { Log.Warning(ex, "Host StopAsync failed (continuing shutdown)"); }
Log.CloseAndFlush(); Log.CloseAndFlush();
_host?.Dispose(); _host?.Dispose();
base.OnExit(e); base.OnExit(e);

View File

@@ -9,15 +9,18 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<!-- Le générateur WinRT (CsWinRT v2.x, déclenché par WebView2/UWP refs sur SDK récent)
émet du code unsafe dans WinRTGenericInstantiation.g.cs. Sans ce flag, build = CS0227. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationIcon>Resources\favicon.ico</ApplicationIcon> <ApplicationIcon>Resources\favicon.ico</ApplicationIcon>
<AssemblyName>PS_Launcher</AssemblyName> <AssemblyName>PS_Launcher</AssemblyName>
<Company>ASTERION VR</Company> <Company>ASTERION VR</Company>
<Product>PROSERVE Launcher</Product> <Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright> <Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace> <RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.23.5</Version> <Version>1.0.13</Version>
<AssemblyVersion>0.23.5.0</AssemblyVersion> <AssemblyVersion>1.0.13.0</AssemblyVersion>
<FileVersion>0.23.5.0</FileVersion> <FileVersion>1.0.13.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) --> <!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>true</PublishSingleFile>
@@ -44,7 +47,7 @@
Le runtime est pré-installé sur Win11 et auto-mis-à-jour via Windows 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 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 --> et on aiguille l'user vers https://go.microsoft.com/fwlink/p/?LinkId=2124703 -->
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2792.45" /> <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3800.47" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,9 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PSLauncher.App.Views; using PSLauncher.App.Views;
using PSLauncher.Core.Configuration; using PSLauncher.Core.Configuration;
using PSLauncher.Core.ApiTool;
using PSLauncher.Core.DocTool; using PSLauncher.Core.DocTool;
using PSLauncher.Core.Lan;
using PSLauncher.Core.Downloads; using PSLauncher.Core.Downloads;
using PSLauncher.Core.Installations; using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing; using PSLauncher.Core.Licensing;
@@ -35,6 +37,10 @@ public sealed partial class SettingsViewModel : ObservableObject
private readonly IDatabaseMigrationService _migrationService; private readonly IDatabaseMigrationService _migrationService;
private readonly IReportToolDeployer _reportDeployer; private readonly IReportToolDeployer _reportDeployer;
private readonly IDocToolDeployer _docDeployer; private readonly IDocToolDeployer _docDeployer;
private readonly IApiToolDeployer _apiDeployer;
private readonly ILanCacheServer _lanCacheServer;
private readonly ILanDiscoveryService _lanDiscovery;
private readonly IZipCacheStore _zipCacheStore;
private readonly IInstallationRegistry _registry; private readonly IInstallationRegistry _registry;
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly ILogger<SettingsViewModel> _logger; private readonly ILogger<SettingsViewModel> _logger;
@@ -85,6 +91,57 @@ public sealed partial class SettingsViewModel : ObservableObject
= new(); = new();
public bool HasDocBackups => DocBackups.Count > 0; public bool HasDocBackups => DocBackups.Count > 0;
// ----- API Tool deploy settings (mirror of Report/Doc) -----
[ObservableProperty] private string _apiHtdocsRoot;
[ObservableProperty] private string _apiFolderName;
[ObservableProperty] private bool _apiAutoDeploy;
[ObservableProperty] private int _apiMaxBackups;
[ObservableProperty] private string? _apiDeployStatus;
[ObservableProperty] private bool _isDeployingApi;
public System.Collections.ObjectModel.ObservableCollection<ApiBackupViewModel> ApiBackups { get; }
= new();
public bool HasApiBackups => ApiBackups.Count > 0;
// ----- SteamVR settings merge -----
[ObservableProperty] private bool _steamVrAutoMerge;
[ObservableProperty] private string _steamVrSettingsPathOverride = string.Empty;
/// <summary>Multi-line text bindé sur un TextBox : 1 process name par ligne (sans .exe).</summary>
[ObservableProperty] private string _steamVrProcessesToKill = string.Empty;
/// <summary>
/// Path canonique attendu si Steam est installé à l'emplacement par défaut.
/// Affiché sous le TextBox d'override pour que l'opérateur sache à quoi ressemble
/// la résolution standard et n'ait pas à le deviner / chercher dans la doc.
/// </summary>
public string SteamVrCanonicalPath =>
PSLauncher.Core.SteamVr.SteamVrSettingsDeployer.CanonicalSettingsPath;
// ----- Cache LAN P2P -----
[ObservableProperty] private bool _lanServerEnabled;
[ObservableProperty] private bool _lanClientEnabled;
[ObservableProperty] private int _lanServerPort;
[ObservableProperty] private int _lanDiscoveryPort;
[ObservableProperty] private int _lanMaxCachedVersions;
/// <summary>Multi-line text bindé sur un TextBox : 1 URL par ligne.</summary>
[ObservableProperty] private string _lanManualPeers = string.Empty;
/// <summary>Statut serveur affiché (rafraîchi à l'ouverture des Settings et toutes les 5 s).</summary>
[ObservableProperty] private string _lanServerStatus = string.Empty;
/// <summary>Liste read-only des peers découverts via UDP, formaté pour l'UI.</summary>
public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new();
public bool HasLanDiscoveredPeers => LanDiscoveredPeers.Count > 0;
// ----- Args par défaut (chaque lancement) -----
/// <summary>Args édités dans la UI. Persiste vers <c>_config.DefaultLaunchArgs</c> au Save.</summary>
public System.Collections.ObjectModel.ObservableCollection<AutoModeArgRowViewModel> DefaultLaunchArgs { get; } = new();
// ----- Mode auto -----
[ObservableProperty] private bool _autoModeFeatureEnabled;
[ObservableProperty] private int _autoModeGraceSeconds;
[ObservableProperty] private string _autoModeSelectedVersion = string.Empty;
/// <summary>Si true → countdown attend que tous les health checks soient OK avant de démarrer.</summary>
[ObservableProperty] private bool _autoModeWaitForHealthChecks;
/// <summary>Args édités dans la UI. Persiste vers <c>_config.AutoMode.Args</c> au Save.</summary>
public System.Collections.ObjectModel.ObservableCollection<AutoModeArgRowViewModel> AutoModeArgs { get; } = new();
// ----- Health checks (bandeau de santé système) ----- // ----- Health checks (bandeau de santé système) -----
/// <summary> /// <summary>
/// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne /// Liste éditable des checks affichés dans le bandeau de santé. Chaque ligne
@@ -165,6 +222,10 @@ public sealed partial class SettingsViewModel : ObservableObject
IDatabaseMigrationService migrationService, IDatabaseMigrationService migrationService,
IReportToolDeployer reportDeployer, IReportToolDeployer reportDeployer,
IDocToolDeployer docDeployer, IDocToolDeployer docDeployer,
IApiToolDeployer apiDeployer,
ILanCacheServer lanCacheServer,
ILanDiscoveryService lanDiscovery,
IZipCacheStore zipCacheStore,
IInstallationRegistry registry, IInstallationRegistry registry,
HttpClient http, HttpClient http,
ILogger<SettingsViewModel> logger) ILogger<SettingsViewModel> logger)
@@ -176,6 +237,10 @@ public sealed partial class SettingsViewModel : ObservableObject
_migrationService = migrationService; _migrationService = migrationService;
_reportDeployer = reportDeployer; _reportDeployer = reportDeployer;
_docDeployer = docDeployer; _docDeployer = docDeployer;
_apiDeployer = apiDeployer;
_lanCacheServer = lanCacheServer;
_lanDiscovery = lanDiscovery;
_zipCacheStore = zipCacheStore;
_registry = registry; _registry = registry;
_http = http; _http = http;
_logger = logger; _logger = logger;
@@ -204,14 +269,44 @@ public sealed partial class SettingsViewModel : ObservableObject
_docAutoDeploy = config.DocTool.AutoDeploy; _docAutoDeploy = config.DocTool.AutoDeploy;
_docMaxBackups = config.DocTool.MaxBackups; _docMaxBackups = config.DocTool.MaxBackups;
_apiHtdocsRoot = config.ApiTool.HtdocsRoot;
_apiFolderName = config.ApiTool.FolderName;
_apiAutoDeploy = config.ApiTool.AutoDeploy;
_apiMaxBackups = config.ApiTool.MaxBackups;
_steamVrAutoMerge = config.SteamVr.AutoMerge;
_steamVrSettingsPathOverride = config.SteamVr.SettingsFilePathOverride ?? string.Empty;
_steamVrProcessesToKill = string.Join(
Environment.NewLine,
config.SteamVr.ProcessesToKill ?? new List<string>());
foreach (var a in config.DefaultLaunchArgs ?? new List<AutoModeArg>())
DefaultLaunchArgs.Add(new AutoModeArgRowViewModel(a.Key, a.Value, RemoveDefaultLaunchArg));
_autoModeFeatureEnabled = config.AutoMode.FeatureEnabled;
_autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds;
_autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty;
_autoModeWaitForHealthChecks = config.AutoMode.WaitForHealthChecks;
foreach (var a in config.AutoMode.Args ?? new List<AutoModeArg>())
AutoModeArgs.Add(new AutoModeArgRowViewModel(a.Key, a.Value, RemoveAutoModeArg));
_lanServerEnabled = config.LanCache.ServerEnabled;
_lanClientEnabled = config.LanCache.ClientEnabled;
_lanServerPort = config.LanCache.ServerPort;
_lanDiscoveryPort = config.LanCache.DiscoveryPort;
_lanMaxCachedVersions = config.LanCache.MaxCachedVersions;
_lanManualPeers = string.Join(Environment.NewLine, config.LanCache.ManualPeerUrls ?? new List<string>());
RefreshLanStatus();
// Snapshot éditable des health checks. On clone chaque entry pour ne pas // Snapshot éditable des health checks. On clone chaque entry pour ne pas
// muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save. // muter _config.HealthChecks.Checks tant que l'utilisateur n'a pas cliqué Save.
foreach (var entry in config.HealthChecks.Checks) foreach (var entry in config.HealthChecks.Checks)
HealthChecks.Add(new HealthCheckRowViewModel(CloneEntry(entry), DeleteHealthCheck)); HealthChecks.Add(new HealthCheckRowViewModel(CloneEntry(entry), DeleteHealthCheck, MoveHealthCheck, CanMoveHealthCheck));
// Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes) // Charge les listes de backups en arrière-plan (UI immédiate puis remplies quand prêtes)
_ = LoadReportBackupsAsync(); _ = LoadReportBackupsAsync();
_ = LoadDocBackupsAsync(); _ = LoadDocBackupsAsync();
_ = LoadApiBackupsAsync();
LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0"; LauncherVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
MachineId = licenseService.GetMachineId(); MachineId = licenseService.GetMachineId();
@@ -316,6 +411,18 @@ public sealed partial class SettingsViewModel : ObservableObject
(Language ?? "auto").ToLowerInvariant(), (Language ?? "auto").ToLowerInvariant(),
StringComparison.Ordinal); StringComparison.Ordinal);
// Détecte les changements LAN qui nécessitent un restart du launcher.
// Les hosted services (LanCacheServer, LanDiscoveryService) lisent leur
// config UNE FOIS au démarrage de l'app — toggler ces flags après coup
// ne réveille pas le service mort. Le port aussi : changer pendant que
// le serveur tourne ne re-bind pas. ManualPeerUrls et MaxCachedVersions
// sont lus à chaque appel donc OK sans restart.
var lanRestartNeeded =
_config.LanCache.ServerEnabled != LanServerEnabled
|| _config.LanCache.ClientEnabled != LanClientEnabled
|| (LanServerEnabled && _config.LanCache.ServerPort != LanServerPort)
|| ((LanServerEnabled || LanClientEnabled) && _config.LanCache.DiscoveryPort != LanDiscoveryPort);
_config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/'); _config.ServerBaseUrl = ServerBaseUrl.TrimEnd('/');
_config.InstallRoot = InstallRoot; _config.InstallRoot = InstallRoot;
_config.Language = Language ?? "auto"; _config.Language = Language ?? "auto";
@@ -343,6 +450,62 @@ public sealed partial class SettingsViewModel : ObservableObject
_config.DocTool.AutoDeploy = DocAutoDeploy; _config.DocTool.AutoDeploy = DocAutoDeploy;
_config.DocTool.MaxBackups = Math.Max(0, DocMaxBackups); _config.DocTool.MaxBackups = Math.Max(0, DocMaxBackups);
_config.ApiTool.HtdocsRoot = ApiHtdocsRoot.Trim();
_config.ApiTool.FolderName = ApiFolderName.Trim();
_config.ApiTool.AutoDeploy = ApiAutoDeploy;
_config.ApiTool.MaxBackups = Math.Max(0, ApiMaxBackups);
_config.SteamVr.AutoMerge = SteamVrAutoMerge;
_config.SteamVr.SettingsFilePathOverride = string.IsNullOrWhiteSpace(SteamVrSettingsPathOverride)
? null : SteamVrSettingsPathOverride.Trim();
_config.SteamVr.ProcessesToKill = (SteamVrProcessesToKill ?? string.Empty)
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(l =>
{
var name = l.Trim();
// Strip .exe défensif (case-insensitive) au cas où l'opérateur le tape.
if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
name = name[..^4].Trim();
return name;
})
.Where(l => l.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
_config.DefaultLaunchArgs = DefaultLaunchArgs
.Select(r => new AutoModeArg
{
Key = (r.Key ?? string.Empty).Trim(),
Value = string.IsNullOrEmpty(r.Value) ? null : r.Value.Trim(),
})
.Where(a => !string.IsNullOrWhiteSpace(a.Key))
.ToList();
_config.AutoMode.FeatureEnabled = AutoModeFeatureEnabled;
_config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60);
_config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion)
? null : AutoModeSelectedVersion.Trim();
_config.AutoMode.WaitForHealthChecks = AutoModeWaitForHealthChecks;
_config.AutoMode.Args = AutoModeArgs
.Select(r => new AutoModeArg
{
Key = (r.Key ?? string.Empty).Trim(),
Value = string.IsNullOrEmpty(r.Value) ? null : r.Value.Trim(),
})
.Where(a => !string.IsNullOrEmpty(a.Key))
.ToList();
_config.LanCache.ServerEnabled = LanServerEnabled;
_config.LanCache.ClientEnabled = LanClientEnabled;
_config.LanCache.ServerPort = LanServerPort > 0 && LanServerPort <= 65535 ? LanServerPort : 47623;
_config.LanCache.DiscoveryPort = LanDiscoveryPort > 0 && LanDiscoveryPort <= 65535 ? LanDiscoveryPort : 47624;
_config.LanCache.MaxCachedVersions = Math.Max(0, LanMaxCachedVersions);
_config.LanCache.ManualPeerUrls = (LanManualPeers ?? string.Empty)
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim())
.Where(l => l.Length > 0)
.ToList();
// Health checks : on remplace intégralement la liste persistée par // Health checks : on remplace intégralement la liste persistée par
// les copies éditées. Les originaux restent en mémoire de toute façon // les copies éditées. Les originaux restent en mémoire de toute façon
// (les VM les ont clonés), donc Cancel a déjà préservé l'état initial. // (les VM les ont clonés), donc Cancel a déjà préservé l'état initial.
@@ -351,36 +514,42 @@ public sealed partial class SettingsViewModel : ObservableObject
_configStore.Save(_config); _configStore.Save(_config);
_logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged); _logger.LogInformation("Settings saved (language={Lang}, changed={Changed})", _config.Language, languageChanged);
// Helper local : prompt + spawn cmd qui attend la fermeture du launcher puis relance.
void PromptRestart(string message, string boxTitle, string logContext)
{
var ok = ThemedMessageBox.Show(
message, boxTitle,
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK);
if (ok != MessageBoxResult.OK) return;
try
{
var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
if (string.IsNullOrEmpty(exe)) return;
var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\"";
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = cmd,
UseShellExecute = false,
CreateNoWindow = true,
});
Application.Current.Shutdown();
}
catch (Exception ex) { _logger.LogError(ex, "Restart on {Context} failed", logContext); }
}
if (languageChanged) if (languageChanged)
{ {
// Le launcher doit être relancé pour que les bindings x:Static reflètent // Le launcher doit être relancé pour que les bindings x:Static reflètent
// la nouvelle langue. Plutôt que de demander à l'utilisateur de fermer/rouvrir, // la nouvelle langue. (Si LAN a aussi changé, le même restart le couvre.)
// on spawne un cmd qui attend la fermeture puis relance. PromptRestart(Strings.MsgLanguageRestart, Strings.MsgBoxLanguageChange, "language change");
var ok = ThemedMessageBox.Show( }
Strings.MsgLanguageRestart, else if (lanRestartNeeded)
Strings.MsgBoxLanguageChange, {
MessageBoxButton.OKCancel, MessageBoxImage.Information, MessageBoxResult.OK); // Les hosted services LAN lisent leur config au démarrage uniquement.
if (ok == MessageBoxResult.OK) // Sans restart, toggler ServerEnabled / ClientEnabled ne réveille pas
{ // le service mort.
try PromptRestart(Strings.MsgLanCacheRestart, Strings.MsgBoxLanCacheChange, "LAN cache toggle");
{
var exe = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
if (!string.IsNullOrEmpty(exe))
{
var pid = Environment.ProcessId;
var cmd = $"/c (echo Wait && timeout /t 1 /nobreak > nul) & start \"\" \"{exe}\"";
Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = cmd,
UseShellExecute = false,
CreateNoWindow = true,
});
Application.Current.Shutdown();
}
}
catch (Exception ex) { _logger.LogError(ex, "Restart on language change failed"); }
}
} }
} }
@@ -673,8 +842,11 @@ public sealed partial class SettingsViewModel : ObservableObject
}; };
if (dlg.ShowDialog() == true && dlg.Saved) if (dlg.ShowDialog() == true && dlg.Saved)
{ {
HealthChecks.Add(new HealthCheckRowViewModel(entry, DeleteHealthCheck)); HealthChecks.Add(new HealthCheckRowViewModel(entry, DeleteHealthCheck, MoveHealthCheck, CanMoveHealthCheck));
OnPropertyChanged(nameof(HasHealthChecks)); OnPropertyChanged(nameof(HasHealthChecks));
// Le nouveau dernier item peut maintenant monter ; l'avant-dernier
// peut maintenant descendre. Refresh global.
RefreshHealthMoveCanExecute();
} }
} }
@@ -687,6 +859,43 @@ public sealed partial class SettingsViewModel : ObservableObject
if (confirm != MessageBoxResult.Yes) return; if (confirm != MessageBoxResult.Yes) return;
HealthChecks.Remove(row); HealthChecks.Remove(row);
OnPropertyChanged(nameof(HasHealthChecks)); OnPropertyChanged(nameof(HasHealthChecks));
RefreshHealthMoveCanExecute();
}
/// <summary>
/// Réordonnancement d'un check via les boutons ▲/▼ de la liste. delta = ±1.
/// La persistance vers <c>_config.HealthChecks.Checks</c> se fait au Save() —
/// d'ici là, on ne mute que la collection observable. ObservableCollection.Move
/// préserve les sélections WPF et déplace l'item sans le re-créer.
/// </summary>
private void MoveHealthCheck(HealthCheckRowViewModel row, int delta)
{
var idx = HealthChecks.IndexOf(row);
var newIdx = idx + delta;
if (idx < 0 || newIdx < 0 || newIdx >= HealthChecks.Count) return;
HealthChecks.Move(idx, newIdx);
RefreshHealthMoveCanExecute();
}
private bool CanMoveHealthCheck(HealthCheckRowViewModel row, int delta)
{
var idx = HealthChecks.IndexOf(row);
var newIdx = idx + delta;
return idx >= 0 && newIdx >= 0 && newIdx < HealthChecks.Count;
}
/// <summary>
/// Re-publie CanExecute sur les commandes ▲/▼ de toutes les lignes : après
/// un déplacement (ou un add/delete), les boundaries changent et les boutons
/// doivent griser/ungrise sur les premiers/derniers items.
/// </summary>
private void RefreshHealthMoveCanExecute()
{
foreach (var r in HealthChecks)
{
r.MoveUpCommand.NotifyCanExecuteChanged();
r.MoveDownCommand.NotifyCanExecuteChanged();
}
} }
private static HealthCheckEntry CloneEntry(HealthCheckEntry src) => new() private static HealthCheckEntry CloneEntry(HealthCheckEntry src) => new()
@@ -741,6 +950,177 @@ public sealed partial class SettingsViewModel : ObservableObject
IsDeployingDoc = false; IsDeployingDoc = false;
} }
} }
// ===================== API TOOL BACKUPS (mirror of Report) =====================
private async Task LoadApiBackupsAsync()
{
try
{
var list = await _apiDeployer.ListBackupsAsync(CancellationToken.None);
ApiBackups.Clear();
foreach (var b in list)
ApiBackups.Add(new ApiBackupViewModel(b, RevertApiToBackupAsync));
OnPropertyChanged(nameof(HasApiBackups));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not list api backups");
}
}
private async Task RevertApiToBackupAsync(BackupInfo backup)
{
var localDate = backup.TimestampUtc.ToLocalTime();
var dateLabel = Strings.FormatLongDate(localDate);
var confirm = ThemedMessageBox.Show(
Strings.MsgRevertReportConfirm(dateLabel),
Strings.MsgBoxRevertReport,
MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
IsDeployingApi = true;
ApiDeployStatus = $"Revert vers {dateLabel}…";
try
{
var result = await _apiDeployer.RevertAsync(backup.FolderName, CancellationToken.None);
ApiDeployStatus = result.Status == DeployStatus.Deployed
? Strings.MsgRevertReportSuccess(dateLabel)
: $"✗ {result.ErrorMessage}";
await LoadApiBackupsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Revert api failed");
ApiDeployStatus = $"✗ {ex.Message}";
}
finally
{
IsDeployingApi = false;
}
}
// ===================== CACHE LAN P2P =====================
/// <summary>
/// Rafraîchit les indicateurs LAN (statut serveur, liste des peers découverts).
/// Appelé à l'init + via la commande "Actualiser" dans la UI.
/// </summary>
[RelayCommand]
private void RefreshLanStatus()
{
// Statut serveur
if (_lanCacheServer.IsRunning && _lanCacheServer.BoundEndpoints.Count > 0)
{
var versions = _zipCacheStore.ListCached().Count;
LanServerStatus = Strings.SettingsLanCacheServerStatus(
string.Join(", ", _lanCacheServer.BoundEndpoints), versions);
}
else
{
LanServerStatus = Strings.SettingsLanCacheServerOff;
}
// Liste des peers découverts (formaté pour l'affichage)
var peers = _lanDiscovery.ListDiscovered();
LanDiscoveredPeers.Clear();
foreach (var p in peers)
{
var versions = p.Versions.Count > 0 ? string.Join(", ", p.Versions) : "—";
LanDiscoveredPeers.Add($"http://{p.Host}:{p.Port} • versions: [{versions}]");
}
OnPropertyChanged(nameof(HasLanDiscoveredPeers));
}
[RelayCommand]
private async Task RedeployApiAsync()
{
var installed = _registry.Scan().OrderByDescending(v => v.Version).FirstOrDefault();
if (installed is null)
{
ApiDeployStatus = "Aucune version installée — installe d'abord une version qui contient _api/.";
return;
}
IsDeployingApi = true;
ApiDeployStatus = $"Déploiement depuis v{installed.Version}…";
try
{
Save();
var progress = new Progress<DeployProgress>(dp =>
{
if (dp.FilesTotal > 0)
ApiDeployStatus = $"{dp.FilesDone}/{dp.FilesTotal} : {dp.CurrentFilename}";
});
var result = await _apiDeployer.DeployAsync(installed.FolderPath, progress, CancellationToken.None);
ApiDeployStatus = result.Status switch
{
DeployStatus.Deployed => $"✓ {result.FilesDeployed} fichiers déployés vers {result.TargetPath}",
DeployStatus.SkippedNoSourceFolder => $"⚠ La version v{installed.Version} ne contient pas de _api/ — rien à déployer",
DeployStatus.XamppNotFound => $"✗ {result.ErrorMessage}",
DeployStatus.Failed => $"✗ {result.ErrorMessage}",
_ => "?",
};
await LoadApiBackupsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Redeploy api failed");
ApiDeployStatus = $"✗ {ex.Message}";
}
finally
{
IsDeployingApi = false;
}
}
// ===================== DEFAULT LAUNCH ARGS =====================
[RelayCommand]
private void AddDefaultLaunchArg()
{
DefaultLaunchArgs.Add(new AutoModeArgRowViewModel(string.Empty, null, RemoveDefaultLaunchArg));
}
private void RemoveDefaultLaunchArg(AutoModeArgRowViewModel row)
{
DefaultLaunchArgs.Remove(row);
}
// ===================== AUTO MODE ARGS =====================
[RelayCommand]
private void AddAutoModeArg()
{
AutoModeArgs.Add(new AutoModeArgRowViewModel(string.Empty, null, RemoveAutoModeArg));
}
private void RemoveAutoModeArg(AutoModeArgRowViewModel row)
{
AutoModeArgs.Remove(row);
}
}
/// <summary>
/// Ligne éditable d'un argument CLI passé à PROSERVE en mode auto. Clé requise,
/// valeur optionnelle (un flag seul comme <c>-autoconnect</c>). Persiste vers
/// <c>_config.AutoMode.Args</c> au Save.
/// </summary>
public sealed partial class AutoModeArgRowViewModel : ObservableObject
{
[ObservableProperty] private string _key;
[ObservableProperty] private string? _value;
private readonly Action<AutoModeArgRowViewModel> _onRemove;
public AutoModeArgRowViewModel(string key, string? value, Action<AutoModeArgRowViewModel> onRemove)
{
_key = key ?? string.Empty;
_value = value;
_onRemove = onRemove;
}
[RelayCommand]
private void Remove() => _onRemove(this);
} }
/// <summary> /// <summary>
@@ -788,11 +1168,19 @@ public sealed partial class HealthCheckRowViewModel : ObservableObject
public string Kind => Entry.Kind; public string Kind => Entry.Kind;
private readonly Action<HealthCheckRowViewModel> _onDelete; private readonly Action<HealthCheckRowViewModel> _onDelete;
private readonly Action<HealthCheckRowViewModel, int> _onMove;
private readonly Func<HealthCheckRowViewModel, int, bool> _canMove;
public HealthCheckRowViewModel(HealthCheckEntry entry, Action<HealthCheckRowViewModel> onDelete) public HealthCheckRowViewModel(
HealthCheckEntry entry,
Action<HealthCheckRowViewModel> onDelete,
Action<HealthCheckRowViewModel, int> onMove,
Func<HealthCheckRowViewModel, int, bool> canMove)
{ {
Entry = entry; Entry = entry;
_onDelete = onDelete; _onDelete = onDelete;
_onMove = onMove;
_canMove = canMove;
_displayName = string.IsNullOrEmpty(entry.Name) ? "(sans nom)" : entry.Name; _displayName = string.IsNullOrEmpty(entry.Name) ? "(sans nom)" : entry.Name;
_displayIcon = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon; _displayIcon = string.IsNullOrEmpty(entry.Icon) ? "🔌" : entry.Icon;
_displayDetail = ComputeDetail(entry); _displayDetail = ComputeDetail(entry);
@@ -823,6 +1211,14 @@ public sealed partial class HealthCheckRowViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private void Delete() => _onDelete(this); private void Delete() => _onDelete(this);
[RelayCommand(CanExecute = nameof(CanMoveUp))]
private void MoveUp() => _onMove(this, -1);
private bool CanMoveUp() => _canMove(this, -1);
[RelayCommand(CanExecute = nameof(CanMoveDown))]
private void MoveDown() => _onMove(this, +1);
private bool CanMoveDown() => _canMove(this, +1);
} }
/// <summary>Variante doc — même structure que <see cref="ReportBackupViewModel"/>.</summary> /// <summary>Variante doc — même structure que <see cref="ReportBackupViewModel"/>.</summary>
@@ -843,3 +1239,22 @@ public sealed partial class DocBackupViewModel : ObservableObject
[RelayCommand] [RelayCommand]
private Task Revert() => _revertHandler(Info); private Task Revert() => _revertHandler(Info);
} }
/// <summary>Variante api — même structure que <see cref="ReportBackupViewModel"/>.</summary>
public sealed partial class ApiBackupViewModel : ObservableObject
{
public BackupInfo Info { get; }
public string DateDisplay => Strings.FormatLongDate(Info.TimestampUtc.ToLocalTime());
public string SizeDisplay => Strings.SettingsReportBackupSize(Strings.FormatSize(Info.SizeBytes));
public string FolderName => Info.FolderName;
private readonly Func<BackupInfo, Task> _revertHandler;
public ApiBackupViewModel(BackupInfo info, Func<BackupInfo, Task> revertHandler)
{
Info = info;
_revertHandler = revertHandler;
}
[RelayCommand]
private Task Revert() => _revertHandler(Info);
}

View File

@@ -26,6 +26,73 @@ public sealed partial class VersionRowViewModel : ObservableObject
public VersionManifest? Remote { get; } public VersionManifest? Remote { get; }
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null; public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
/// <summary>
/// Clé stable qui identifie une row au-delà du simple <see cref="Version"/>,
/// pour distinguer deux entrées manifest partageant le même numéro sur des
/// channels différents (ex : proserve-firefighter-1.5.4.32 vs proserve-full-
/// 1.5.4.32). Priorité :
/// <list type="number">
/// <item>Basename du dossier d'install (côté installed, ou côté remote
/// résolu via <c>InstallFolderTemplate</c>) — unique par convention car
/// deux entries au même version DOIVENT avoir des dossiers distincts
/// (validé backoffice + install guard client).</item>
/// <item>Fallback : le <see cref="Version"/> seul (mono-channel, comportement
/// identique à avant).</item>
/// </list>
/// Utilisé pour identifier une row lors des lookups par état actif (DL en
/// cours, install courant), au lieu de matcher par <c>Version</c> qui devient
/// ambigu sur les setups multi-channels.
/// </summary>
public string RowKey { get; }
/// <summary>
/// Channel principal à afficher comme badge sur la row (null / vide → pas
/// de badge). Sélection : le premier channel non-« default » de
/// <see cref="VersionManifest.Channels"/>, en <c>OrdinalIgnoreCase</c>. Pour
/// une row installée sans remote (orpheline), c'est null. Pour une entrée
/// remote sans Channels ou uniquement « default », c'est null.
/// </summary>
public string? ChannelBadge => Remote?.Channels?
.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c)
&& !string.Equals(c, "default", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// True si au moins une AUTRE row visible sur ce launcher partage le même
/// numéro de version. Positionné par <c>MainViewModel.RebuildList</c> après
/// construction des rows (group by Version, count &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] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(StateLabel))] [NotifyPropertyChangedFor(nameof(StateLabel))]
[NotifyPropertyChangedFor(nameof(IsBusy))] [NotifyPropertyChangedFor(nameof(IsBusy))]
@@ -53,6 +120,44 @@ public sealed partial class VersionRowViewModel : ObservableObject
[NotifyCanExecuteChangedFor(nameof(InstallCommand))] [NotifyCanExecuteChangedFor(nameof(InstallCommand))]
private bool _licenseAllowsDownload = true; private bool _licenseAllowsDownload = true;
/// <summary>
/// True quand cette version est la version désignée pour l'auto-launch
/// (bouton AUTO orange/coloré). Une seule row peut être sélectionnée à la
/// fois ; le MainViewModel maintient l'unicité quand on toggle.
/// </summary>
[ObservableProperty]
private bool _isAutoModeSelected;
/// <summary>
/// True si le bouton AUTO doit être affiché sur cette row (master toggle ON
/// + version installée). Piloté par le MainViewModel via property update à
/// chaque RebuildList.
/// </summary>
[ObservableProperty]
private bool _showAutoButton;
public Action<VersionRowViewModel>? ToggleAutoModeHandler { get; set; }
[RelayCommand]
private void ToggleAutoMode()
{
// Trace fichier pour diagnostiquer "le bouton AUTO ne fait rien".
// Si on voit cette ligne mais pas la suivante côté MainViewModel,
// c'est que le handler n'est pas attaché (bug WireRowHandlers).
try
{
var dir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PSLauncher", "logs");
System.IO.Directory.CreateDirectory(dir);
System.IO.File.AppendAllText(
System.IO.Path.Combine(dir, "auto-mode.log"),
$"{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}Z VersionRowViewModel.ToggleAutoMode invoked for v{Version} (handler attached={ToggleAutoModeHandler is not null}){Environment.NewLine}");
}
catch { }
ToggleAutoModeHandler?.Invoke(this);
}
public bool HasResumableDownload => ResumableBytes > 0; public bool HasResumableDownload => ResumableBytes > 0;
public bool LicenseAllowsInstall => LicenseAllowsDownload; public bool LicenseAllowsInstall => LicenseAllowsDownload;
@@ -141,6 +246,19 @@ public sealed partial class VersionRowViewModel : ObservableObject
Installed = installed; Installed = installed;
Remote = remote; Remote = remote;
_state = initialState; _state = initialState;
// RowKey — priorité : (1) Id de l'entrée manifest (unique par entry, survit
// à un rename de folder), (2) EntryId lu depuis le .proserve-meta.json de
// l'install, (3) folder name basename, (4) version en dernier recours.
// Doit matcher la logique de MainViewModel.RebuildList pour que les lookups
// par RowKey trouvent la row correcte.
var folderName = !string.IsNullOrEmpty(folderPath)
? System.IO.Path.GetFileName(folderPath)
: remote?.GetInstallFolderName();
RowKey = !string.IsNullOrEmpty(remote?.Id) ? remote!.Id!
: !string.IsNullOrEmpty(installed?.EntryId) ? installed!.EntryId!
: !string.IsNullOrWhiteSpace(folderName) ? folderName!
: version;
} }
// Les commandes sont câblées par le MainViewModel après instanciation // Les commandes sont câblées par le MainViewModel après instanciation
@@ -152,6 +270,7 @@ public sealed partial class VersionRowViewModel : ObservableObject
public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; } public Action<VersionRowViewModel>? ShowReleaseNotesHandler { get; set; }
public Action<VersionRowViewModel>? OpenFolderHandler { get; set; } public Action<VersionRowViewModel>? OpenFolderHandler { get; set; }
public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; } public Action<VersionRowViewModel>? RestartFromZeroHandler { get; set; }
public Action<VersionRowViewModel>? ForceFreshDownloadHandler { get; set; }
[RelayCommand(CanExecute = nameof(CanLaunch))] [RelayCommand(CanExecute = nameof(CanLaunch))]
private void Launch() => LaunchHandler?.Invoke(this); private void Launch() => LaunchHandler?.Invoke(this);
@@ -175,6 +294,15 @@ public sealed partial class VersionRowViewModel : ObservableObject
private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this); private void RestartFromZero() => RestartFromZeroHandler?.Invoke(this);
private bool CanRestartFromZero() => HasResumableDownload; private bool CanRestartFromZero() => HasResumableDownload;
/// <summary>
/// Visible quand la row n'est pas en cours de DL (sinon le user devrait
/// d'abord cancel via le bouton dédié). Pas conditionnée par "partial existe"
/// car cette action a pour but spécifique de purger TOUT (cache + partial +
/// sidecar SHA), même quand il n'y a pas de partial en cours.
/// </summary>
[RelayCommand]
private void ForceFreshDownload() => ForceFreshDownloadHandler?.Invoke(this);
private static string FormatSize(long bytes) private static string FormatSize(long bytes)
=> bytes <= 0 ? "—" : Strings.FormatSize(bytes); => 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

@@ -11,6 +11,7 @@
Width="1280" Height="720" Width="1280" Height="720"
MinWidth="980" MinHeight="600" MinWidth="980" MinHeight="600"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
WindowState="Maximized"
WindowStyle="None" WindowStyle="None"
ResizeMode="CanResize" ResizeMode="CanResize"
AllowsTransparency="False" AllowsTransparency="False"
@@ -84,6 +85,31 @@
FontSize="15" FontWeight="SemiBold" FontSize="15" FontWeight="SemiBold"
VerticalAlignment="Center" VerticalAlignment="Center"
Foreground="{StaticResource Brush.Text.Primary}" /> Foreground="{StaticResource Brush.Text.Primary}" />
<!-- Badge BÊTA pour les rows secondaires (compact). Même look
que celui du featured mais en plus petit pour matcher la row. -->
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
VerticalAlignment="Center"
Background="#F59E0B"
ToolTip="{Binding BetaTooltip}"
Visibility="{Binding IsBeta, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
FontSize="10" FontWeight="Bold"
Foreground="#1A0A00" />
</Border>
<!-- Badge CHANNEL : violet, à côté des autres badges. Affiché
quand la row est sur un channel spécifique (non-default) et
qu'une autre row visible partage le même numéro de version.
Couleur violette #8B5CF6 pour se distinguer nettement du
badge BETA orange et du bleu du statut "Available". -->
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
VerticalAlignment="Center"
Background="#8B5CF6"
ToolTip="{Binding ChannelBadge}"
Visibility="{Binding HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding ChannelBadge}"
FontSize="10" FontWeight="Bold"
Foreground="White" />
</Border>
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0" <Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Border.Style> <Border.Style>
@@ -114,6 +140,27 @@
<!-- Action button --> <!-- Action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0"> <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0">
<!-- Bouton AUTO : sélectionne cette version pour l'auto-launch
au démarrage du launcher. Fond gris quand inactif, orange
quand sélectionné. Visible seulement si FeatureEnabled. -->
<Button Content="{x:Static loc:Strings.ActionAuto}"
Padding="14,8" FontSize="13" FontWeight="Bold"
Margin="0,0,6,0"
Command="{Binding ToggleAutoModeCommand}"
Visibility="{Binding ShowAutoButton, Converter={StaticResource BoolToVisibility}}">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource SecondaryButton}">
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipInactive}" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsAutoModeSelected}" Value="True">
<Setter Property="Background" Value="#F5A623" />
<Setter Property="Foreground" Value="White" />
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipActive}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button Style="{StaticResource PrimaryButton}" <Button Style="{StaticResource PrimaryButton}"
Content="{x:Static loc:Strings.ActionLaunch}" Padding="22,8" FontSize="13" Content="{x:Static loc:Strings.ActionLaunch}" Padding="22,8" FontSize="13"
Command="{Binding LaunchCommand}" Command="{Binding LaunchCommand}"
@@ -161,6 +208,8 @@
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}" <MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" /> Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" /> <Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}" <MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
@@ -193,6 +242,10 @@
de fenêtre. Le footer est confiné à la colonne content. de fenêtre. Le footer est confiné à la colonne content.
--> -->
<Grid Background="{StaticResource Brush.Bg.Window}"> <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> <Grid.ColumnDefinitions>
<ColumnDefinition Width="200" /> <ColumnDefinition Width="200" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
@@ -467,6 +520,32 @@
<TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='PROSERVE v{0}'}" <TextBlock Text="{Binding FeaturedVersion.Version, StringFormat='PROSERVE v{0}'}"
FontSize="32" FontWeight="Bold" FontSize="32" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Primary}" /> Foreground="{StaticResource Brush.Text.Primary}" />
<!-- Badge BÊTA : pill orange à côté de la version. Visible
uniquement quand isBeta=true dans le manifest. Tooltip
dynamique avec les notes des testeurs s'il y en a. -->
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
VerticalAlignment="Center"
Background="#F59E0B"
ToolTip="{Binding FeaturedVersion.BetaTooltip}"
Visibility="{Binding FeaturedVersion.IsBeta, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{x:Static loc:Strings.BetaBadgeLabel}"
FontSize="11" FontWeight="Bold"
Foreground="#1A0A00" />
</Border>
<!-- Badge CHANNEL : pill violette. Visible sur les entrées
manifest taggées avec un channel non-default (firefighter,
police, etc.) quand une autre row partage le numéro de
version. Violet #8B5CF6 pour se distinguer du orange BETA
et du bleu du statut "Available". -->
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
VerticalAlignment="Center"
Background="#8B5CF6"
ToolTip="{Binding FeaturedVersion.ChannelBadge}"
Visibility="{Binding FeaturedVersion.HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding FeaturedVersion.ChannelBadge}"
FontSize="11" FontWeight="Bold"
Foreground="White" />
</Border>
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0" <Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Border.Style> <Border.Style>
@@ -499,13 +578,34 @@
</TextBlock> </TextBlock>
<!-- Action principale (gros bouton) --> <!-- Action principale (gros bouton) -->
<Button Grid.Row="0" Grid.RowSpan="3" Grid.Column="1" <StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Style="{StaticResource PrimaryButton}" Orientation="Horizontal" VerticalAlignment="Center"
Content="{x:Static loc:Strings.ActionLaunchBig}" Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}">
FontSize="20" Padding="56,16" <!-- Bouton AUTO featured (taille assortie au gros LANCER) -->
VerticalAlignment="Center" <Button Content="{x:Static loc:Strings.ActionAuto}"
Command="{Binding FeaturedVersion.LaunchCommand}" FontSize="16" FontWeight="Bold"
Visibility="{Binding FeaturedVersion.IsInstalled, Converter={StaticResource BoolToVisibility}}" /> Padding="20,16"
Margin="0,0,12,0"
Command="{Binding FeaturedVersion.ToggleAutoModeCommand}"
Visibility="{Binding FeaturedVersion.ShowAutoButton, Converter={StaticResource BoolToVisibility}}">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource SecondaryButton}">
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipInactive}" />
<Style.Triggers>
<DataTrigger Binding="{Binding FeaturedVersion.IsAutoModeSelected}" Value="True">
<Setter Property="Background" Value="#F5A623" />
<Setter Property="Foreground" Value="White" />
<Setter Property="ToolTip" Value="{x:Static loc:Strings.AutoModeTooltipActive}" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button Style="{StaticResource PrimaryButton}"
Content="{x:Static loc:Strings.ActionLaunchBig}"
FontSize="20" Padding="56,16"
Command="{Binding FeaturedVersion.LaunchCommand}" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1" <StackPanel Grid.Row="0" Grid.RowSpan="3" Grid.Column="1"
Orientation="Vertical" VerticalAlignment="Center" Orientation="Vertical" VerticalAlignment="Center"
@@ -561,6 +661,8 @@
<MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}" <MenuItem Header="{x:Static loc:Strings.MenuRestartFromZero}"
Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" Command="{Binding PlacementTarget.Tag.RestartFromZeroCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" /> Visibility="{Binding PlacementTarget.Tag.HasResumableDownload, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuForceFreshDownload}"
Command="{Binding PlacementTarget.Tag.ForceFreshDownloadCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" />
<Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" /> <Separator Visibility="{Binding PlacementTarget.Tag.IsInstalled, RelativeSource={RelativeSource AncestorType=ContextMenu}, Converter={StaticResource BoolToVisibility}}" />
<MenuItem Header="{x:Static loc:Strings.MenuUninstall}" <MenuItem Header="{x:Static loc:Strings.MenuUninstall}"
Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}" Command="{Binding PlacementTarget.Tag.UninstallCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
@@ -614,9 +716,11 @@
Command="{Binding CheckForUpdatesCommand}" /> Command="{Binding CheckForUpdatesCommand}" />
<!-- Copyright flottant en bas centré, sur une pill sombre pour rester <!-- Copyright flottant en bas centré, sur une pill sombre pour rester
lisible quel que soit l'arrière-plan (image, voile noir, etc.). --> lisible quel que soit l'arrière-plan (image, voile noir, etc.).
Bottom margin volontairement faible pour rester collé au footer
(barre de DL) quand il s'affiche. -->
<Border HorizontalAlignment="Center" VerticalAlignment="Bottom" <Border HorizontalAlignment="Center" VerticalAlignment="Bottom"
Margin="0,0,0,28" Margin="0,0,0,8"
Background="#A0000000" Background="#A0000000"
CornerRadius="10" CornerRadius="10"
Padding="10,4"> Padding="10,4">
@@ -661,33 +765,108 @@
Background="#0A0A0E" Background="#0A0A0E"
BorderBrush="{StaticResource Brush.Border}" BorderBrush="{StaticResource Brush.Border}"
BorderThickness="0,0,1,0"> BorderThickness="0,0,1,0">
<StackPanel Orientation="Vertical" Margin="0,16,0,0"> <DockPanel LastChildFill="True">
<!-- Style des boutons définis dans Theme.xaml (NavButton) --> <!-- Bloc d'info en bas de sidebar : version du launcher + IP locale.
<Button Style="{StaticResource NavButton}" Utilité diagnostic terrain : le support peut lire les deux infos
Command="{Binding NavigateLibraryCommand}" sans avoir à naviguer dans Windows ou About. DockPanel.Dock=Bottom
Tag="{Binding IsLibrary}"> pour rester collé au bas peu importe la hauteur de la fenêtre. -->
<StackPanel Orientation="Horizontal"> <Border DockPanel.Dock="Bottom"
<TextBlock Text="📚" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" /> BorderBrush="{StaticResource Brush.Border}"
<TextBlock Text="{x:Static loc:Strings.NavLibrary}" VerticalAlignment="Center" /> BorderThickness="0,1,0,0"
</StackPanel> Padding="16,10">
</Button> <!-- Grid 2 colonnes : la colonne label (Auto) prend la largeur du
<Button Style="{StaticResource NavButton}" plus long ("PS_Launcher:"), la colonne valeur démarre au même X
Command="{Binding NavigateReportCommand}" sur les deux lignes → version et IP visuellement alignées. -->
Tag="{Binding IsReport}"> <Grid>
<StackPanel Orientation="Horizontal"> <Grid.ColumnDefinitions>
<TextBlock Text="📊" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" /> <ColumnDefinition Width="Auto" />
<TextBlock Text="{x:Static loc:Strings.NavReport}" VerticalAlignment="Center" /> <ColumnDefinition Width="*" />
</StackPanel> </Grid.ColumnDefinitions>
</Button> <Grid.RowDefinitions>
<Button Style="{StaticResource NavButton}" <RowDefinition Height="Auto" />
Command="{Binding NavigateDocumentationCommand}" <RowDefinition Height="Auto" />
Tag="{Binding IsDocumentation}"> <RowDefinition Height="Auto" />
<StackPanel Orientation="Horizontal"> <RowDefinition Height="Auto" />
<TextBlock Text="📖" FontSize="16" Margin="0,0,12,0" VerticalAlignment="Center" /> <RowDefinition Height="Auto" />
<TextBlock Text="{x:Static loc:Strings.NavDocumentation}" VerticalAlignment="Center" /> </Grid.RowDefinitions>
</StackPanel> <TextBlock Grid.Row="0" Grid.Column="0"
</Button> Text="PS_Launcher"
</StackPanel> 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> </Border>
<!-- Footer : Row 3, Col 1 → cantonné à la colonne content, sous la zone <!-- Footer : Row 3, Col 1 → cantonné à la colonne content, sous la zone
@@ -717,7 +896,7 @@
Content="{x:Static loc:Strings.ActionCancel}" Content="{x:Static loc:Strings.ActionCancel}"
VerticalAlignment="Center" Margin="12,0,0,0" VerticalAlignment="Center" Margin="12,0,0,0"
Command="{Binding CancelDownloadCommand}" Command="{Binding CancelDownloadCommand}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" /> Visibility="{Binding IsDownloadActive, Converter={StaticResource BoolToVisibility}}" />
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>

View File

@@ -3,6 +3,7 @@ using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Controls.Primitives; using System.Windows.Controls.Primitives;
using System.Windows.Interop; using System.Windows.Interop;
using Microsoft.Web.WebView2.Wpf;
using PSLauncher.App.ViewModels; using PSLauncher.App.ViewModels;
using PSLauncher.Core.Localization; using PSLauncher.Core.Localization;
@@ -25,11 +26,89 @@ public partial class MainWindow : Window
private const int SW_RESTORE = 9; private const int SW_RESTORE = 9;
// ===== WM_GETMINMAXINFO : contraint la taille en mode Maximized à la work area =====
// Avec WindowStyle="None" + WindowChrome, Windows maximise la fenêtre à la taille
// PHYSIQUE du moniteur (incluant la zone de la taskbar), ce qui fait passer le footer
// sous la barre des tâches → barre de DL invisible. Le fix canonique est de hooker
// WM_GETMINMAXINFO et de remplacer ptMaxSize/ptMaxPosition par les coordonnées de
// la work area (rcWork) du moniteur sur lequel la fenêtre se trouve. Multi-écran-safe
// (gère même les taskbars sur écran secondaire) et insensible au DPI.
private const int WM_GETMINMAXINFO = 0x0024;
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr handle, uint flags);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)]
private struct MINMAXINFO
{
public POINT ptReserved;
public POINT ptMaxSize;
public POINT ptMaxPosition;
public POINT ptMinTrackSize;
public POINT ptMaxTrackSize;
}
[StructLayout(LayoutKind.Sequential)]
private struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
private static void HandleGetMinMaxInfo(IntPtr hwnd, IntPtr lParam)
{
var monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
if (monitor == IntPtr.Zero) return;
var info = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
if (!GetMonitorInfo(monitor, ref info)) return;
var mmi = Marshal.PtrToStructure<MINMAXINFO>(lParam);
var work = info.rcWork;
var monitorRect = info.rcMonitor;
mmi.ptMaxPosition.X = Math.Abs(work.Left - monitorRect.Left);
mmi.ptMaxPosition.Y = Math.Abs(work.Top - monitorRect.Top);
mmi.ptMaxSize.X = Math.Abs(work.Right - work.Left);
mmi.ptMaxSize.Y = Math.Abs(work.Bottom - work.Top);
Marshal.StructureToPtr(mmi, lParam, true);
}
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
SourceInitialized += MainWindow_SourceInitialized; SourceInitialized += MainWindow_SourceInitialized;
Closing += MainWindow_Closing; 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> /// <summary>
@@ -73,6 +152,12 @@ public partial class MainWindow : Window
SetForegroundWindow(hwnd); SetForegroundWindow(hwnd);
handled = true; handled = true;
} }
else if (msg == WM_GETMINMAXINFO)
{
HandleGetMinMaxInfo(hwnd, lParam);
// On laisse handled=false : Windows continue son traitement avec le MINMAXINFO
// qu'on vient de réécrire (clamp à la work area du moniteur).
}
return IntPtr.Zero; return IntPtr.Zero;
} }

View File

@@ -174,11 +174,13 @@
importantes (License, Langue, About) qui restent dépliées. 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. Placé en BAS de la fenêtre car ce sont des trucs de power-user / support.
--> -->
<Expander Header="{x:Static loc:Strings.SettingsAdvanced}" <Expander x:Name="AdvancedExpander"
Header="{x:Static loc:Strings.SettingsAdvanced}"
Foreground="{StaticResource Brush.Text.Secondary}" Foreground="{StaticResource Brush.Text.Secondary}"
FontSize="11" FontWeight="Bold" FontSize="11" FontWeight="Bold"
Background="Transparent" BorderThickness="0" Background="Transparent" BorderThickness="0"
Margin="0,0,0,16" IsExpanded="False"> Margin="0,0,0,16" IsExpanded="False"
Expanded="OnAdvancedExpanded">
<StackPanel Margin="0,12,0,0"> <StackPanel Margin="0,12,0,0">
<!-- Serveur --> <!-- Serveur -->
@@ -538,6 +540,431 @@
</StackPanel> </StackPanel>
</Border> </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) --> <!-- Outil Doc (XAMPP htdocs deploy, mirror of Report) -->
<Border Background="{StaticResource Brush.Bg.Card}" <Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
@@ -695,6 +1122,8 @@
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" <TextBlock Grid.Column="0"
Text="{Binding DisplayIcon}" Text="{Binding DisplayIcon}"
@@ -710,13 +1139,29 @@
FontSize="11" FontSize="11"
FontFamily="Consolas" /> FontFamily="Consolas" />
</StackPanel> </StackPanel>
<!-- Boutons ▲/▼ pour réordonner. CanExecute géré côté
ViewModel : grisés sur le premier/dernier item. -->
<Button Grid.Column="2" <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}" Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthEdit}" Content="{x:Static loc:Strings.SettingsHealthEdit}"
Command="{Binding EditCommand}" Command="{Binding EditCommand}"
Margin="0,0,6,0" Margin="0,0,6,0"
Padding="12,6" /> Padding="12,6" />
<Button Grid.Column="3" <Button Grid.Column="5"
Style="{StaticResource SecondaryButton}" Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsHealthDelete}" Content="{x:Static loc:Strings.SettingsHealthDelete}"
Command="{Binding DeleteCommand}" Command="{Binding DeleteCommand}"

View File

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

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

@@ -14,6 +14,7 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="*" /> <RowDefinition Height="*" />
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Grid.Row="0" <TextBlock Grid.Row="0"
@@ -37,7 +38,16 @@
Background="Transparent" /> Background="Transparent" />
</Border> </Border>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0"> <CheckBox Grid.Row="3"
x:Name="PreserveSaveGamesCheckBox"
Content="{x:Static loc:Strings.UpdatePreserveSaveGames}"
ToolTip="{x:Static loc:Strings.UpdatePreserveSaveGamesTooltip}"
IsChecked="True"
Foreground="{StaticResource Brush.Text.Primary}"
Margin="0,14,0,0"
HorizontalAlignment="Left" />
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Style="{StaticResource SecondaryButton}" <Button Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.ActionLater}" Content="{x:Static loc:Strings.ActionLater}"
IsCancel="True" IsCancel="True"

View File

@@ -24,9 +24,18 @@ public partial class UpdateAvailableDialog : Window
public bool DownloadRequested { get; private set; } public bool DownloadRequested { get; private set; }
/// <summary>
/// État de la case « Conserver les sauvegardes de la version précédente ».
/// Cochée par défaut, lue par MainViewModel APRÈS l'extraction du ZIP pour
/// décider s'il faut copier les .sav depuis l'install précédente vers la
/// nouvelle (voir CopyPreviousSaveGamesAsync).
/// </summary>
public bool PreserveSaveGames { get; private set; } = true;
private void OnDownload(object sender, RoutedEventArgs e) private void OnDownload(object sender, RoutedEventArgs e)
{ {
DownloadRequested = true; DownloadRequested = true;
PreserveSaveGames = PreserveSaveGamesCheckBox.IsChecked == true;
DialogResult = true; DialogResult = true;
Close(); Close();
} }

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

@@ -45,9 +45,20 @@ public sealed class DocToolDeployer : IDocToolDeployer
if (!Directory.Exists(cfg.HtdocsRoot)) if (!Directory.Exists(cfg.HtdocsRoot))
{ {
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot); // Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale).
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target, try
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancés)"); {
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 stagingTarget = target + ".new";

View File

@@ -62,7 +62,11 @@ public sealed class DownloadManager : IDownloadManager
_retryPipeline = new ResiliencePipelineBuilder() _retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions .AddRetry(new RetryStrategyOptions
{ {
MaxRetryAttempts = 6, // 10 tentatives × backoff 1→32s avec jitter ≈ ~3 min de retry budget
// par segment. Couvre les outages PHP-FPM courts en multi-PC (où des
// segments peuvent être tués mid-stream par request_terminate_timeout)
// sans donner l'impression d'une boucle infinie en cas de panne réelle.
MaxRetryAttempts = 10,
BackoffType = DelayBackoffType.Exponential, BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(1), Delay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(32), MaxDelay = TimeSpan.FromSeconds(32),
@@ -294,7 +298,12 @@ public sealed class DownloadManager : IDownloadManager
// 4. Lance les workers en parallèle. // 4. Lance les workers en parallèle.
// Compteur agrégé via Interlocked → pas de lock dans le hot path. // 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). // Action passée aux segments : juste un Add atomique (zéro contention).
void OnSegmentBytes(int _, long deltaBytes) void OnSegmentBytes(int _, long deltaBytes)
@@ -303,14 +312,26 @@ public sealed class DownloadManager : IDownloadManager
// Reporter dédié : toutes les 250 ms, snapshot le compteur, calcule bps/ETA, reporte. // 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. // 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. // 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); using var reporterCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var reporterTask = Task.Run(async () => var reporterTask = Task.Run(async () =>
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var lastReport = TimeSpan.Zero;
long lastReportBytes = Interlocked.Read(ref aggregateBytes);
var lastFlush = TimeSpan.Zero; 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 try
{ {
while (!reporterCts.IsCancellationRequested) while (!reporterCts.IsCancellationRequested)
@@ -318,13 +339,24 @@ public sealed class DownloadManager : IDownloadManager
await Task.Delay(250, reporterCts.Token).ConfigureAwait(false); await Task.Delay(250, reporterCts.Token).ConfigureAwait(false);
var snapshot = Interlocked.Read(ref aggregateBytes); var snapshot = Interlocked.Read(ref aggregateBytes);
var elapsed = sw.Elapsed; 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; 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)); progress?.Report(new DownloadProgress(snapshot, total, bps, eta));
lastReport = elapsed;
lastReportBytes = snapshot;
if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval || if ((elapsed - lastFlush).TotalSeconds >= StateFlushSecondsInterval ||
snapshot - lastFlushBytes >= StateFlushBytesInterval) snapshot - lastFlushBytes >= StateFlushBytesInterval)
@@ -369,7 +401,22 @@ public sealed class DownloadManager : IDownloadManager
_stateStore.Save(state); _stateStore.Save(state);
progress?.Report(new DownloadProgress(state.DownloadedBytes, total, 0, TimeSpan.Zero)); 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; var actualSize = new FileInfo(partialPath).Length;
if (actualSize != total) if (actualSize != total)
{ {
@@ -431,16 +478,84 @@ public sealed class DownloadManager : IDownloadManager
throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true); throw new HttpResumableException("Server returned 200 for segment range request", isTransient: true);
} }
// 403 / 410 = URL HMAC expirée (gate.php renvoie 403 "Forbidden: expired"). // 403 / 410 / 404 = URL HMAC expirée (gate.php renvoie 403 "Forbidden:
// On force un refresh d'URL via le callback du job, puis on jette une // expired"), OU le fichier a été renommé/supprimé côté serveur (cas
// exception transitoire pour que Polly retry avec la nouvelle URL. // typique : ré-upload du ZIP avec un nouveau nom pour invalider le cache
if (resp.StatusCode == HttpStatusCode.Forbidden || resp.StatusCode == HttpStatusCode.Gone) // 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)
{ {
if (job.RefreshUrlAsync is not null) await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false);
bool urlChanged = !Uri.Equals(url, _currentUrl);
if (urlChanged)
{ {
await GetOrRefreshUrlAsync(job, forceRefresh: true, ct).ConfigureAwait(false); _logger.LogInformation(
throw new HttpResumableException("Signed URL expired, refreshed for next try", isTransient: true); "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) if (!resp.IsSuccessStatusCode)
{ {
@@ -455,16 +570,67 @@ public sealed class DownloadManager : IDownloadManager
await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true); await using var dst = new FileStream(partialPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite, BufferSize, useAsync: true);
dst.Seek(segStart, SeekOrigin.Begin); 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]; var buffer = new byte[BufferSize];
long inFlight = 0;
int n; int n;
while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0) while ((n = await src.ReadAsync(buffer.AsMemory(0, BufferSize), ct).ConfigureAwait(false)) > 0)
{ {
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false); await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
seg.DownloadedBytes += n; inFlight += n;
onBytes(seg.Index, 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); 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

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

View File

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

View File

@@ -1,18 +1,52 @@
namespace PSLauncher.Core.Installations; namespace PSLauncher.Core.Installations;
public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable<SemVer> /// <summary>
/// Version produit PROSERVE. Formats acceptés :
/// <list type="bullet">
/// <item><c>X.Y.Z</c> (3 digits — releases publiques)</item>
/// <item><c>X.Y.Z.B</c> (4 digits — itérations dev/test internes)</item>
/// </list>
/// Le 4ᵉ digit <see cref="Build"/> est sémantiquement "post-release" :
/// <c>1.5.4 == 1.5.4.0 &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) public static SemVer Parse(string s)
{ {
if (string.IsNullOrWhiteSpace(s)) return new SemVer(0, 0, 0);
var parts = s.Split('.'); var parts = s.Split('.');
if (parts.Length != 3 // 3 digits = release publique (X.Y.Z), 4 digits = itération dev (X.Y.Z.B).
|| !int.TryParse(parts[0], out var maj) // Tout autre format = parse-fail → on retourne 0.0.0 (silencieux, comme
// l'ancien comportement, pour ne pas casser sur un manifest exotique).
if (parts.Length < 3 || parts.Length > 4) return new SemVer(0, 0, 0);
if (!int.TryParse(parts[0], out var maj)
|| !int.TryParse(parts[1], out var min) || !int.TryParse(parts[1], out var min)
|| !int.TryParse(parts[2], out var pat)) || !int.TryParse(parts[2], out var pat))
{ {
return new SemVer(0, 0, 0); return new SemVer(0, 0, 0);
} }
return new SemVer(maj, min, pat); if (parts.Length == 3)
{
return new SemVer(maj, min, pat, 0) { HasExplicitBuild = false };
}
// 4 digits — itération dev
if (!int.TryParse(parts[3], out var build))
{
return new SemVer(0, 0, 0);
}
return new SemVer(maj, min, pat, build) { HasExplicitBuild = true };
} }
public int CompareTo(SemVer other) public int CompareTo(SemVer other)
@@ -21,8 +55,13 @@ public readonly record struct SemVer(int Major, int Minor, int Patch) : ICompara
if (c != 0) return c; if (c != 0) return c;
c = Minor.CompareTo(other.Minor); c = Minor.CompareTo(other.Minor);
if (c != 0) return c; if (c != 0) return c;
return Patch.CompareTo(other.Patch); c = Patch.CompareTo(other.Patch);
if (c != 0) return c;
// Build absent = 0 implicite → 1.5.4 == 1.5.4.0 < 1.5.4.1
return Build.CompareTo(other.Build);
} }
public override string ToString() => $"{Major}.{Minor}.{Patch}"; public override string ToString() => HasExplicitBuild || Build > 0
? $"{Major}.{Minor}.{Patch}.{Build}"
: $"{Major}.{Minor}.{Patch}";
} }

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

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. /// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version.
/// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur), /// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
/// auquel cas l'appelant retombe sur l'URL publique du manifest. /// auquel cas l'appelant retombe sur l'URL publique du manifest.
/// <paramref name="expectedFilename"/> est le nom du fichier attendu, extrait du
/// <c>download.url</c> côté manifest client. Sert au serveur à disambiguer les
/// entrées quand plusieurs channels partagent le même numéro de version
/// (proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32 sur v1.5.4.32).
/// Si null ou vide, le serveur retombe sur "1re entrée matchant le numéro"
/// (rétro-compat avec les vieux serveurs qui ignorent ce paramètre).
/// </summary> /// </summary>
Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct); Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct);
/// <summary> /// <summary>
/// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null /// 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 Microsoft.Extensions.Logging;
using NSec.Cryptography; using NSec.Cryptography;
using PSLauncher.Core.Configuration; using PSLauncher.Core.Configuration;
using PSLauncher.Core.Security;
using PSLauncher.Models; using PSLauncher.Models;
namespace PSLauncher.Core.Licensing; namespace PSLauncher.Core.Licensing;
@@ -36,6 +37,7 @@ public sealed class LicenseService : ILicenseService
private readonly Func<string> _serverBaseUrlProvider; private readonly Func<string> _serverBaseUrlProvider;
private readonly IConfigStore _configStore; private readonly IConfigStore _configStore;
private readonly LocalConfig _config; private readonly LocalConfig _config;
private readonly ISettingsLockService _settingsLock;
private readonly ILogger<LicenseService> _logger; private readonly ILogger<LicenseService> _logger;
private readonly string? _serverPublicKeyHex; private readonly string? _serverPublicKeyHex;
@@ -44,10 +46,12 @@ public sealed class LicenseService : ILicenseService
Func<string> serverBaseUrlProvider, Func<string> serverBaseUrlProvider,
IConfigStore configStore, IConfigStore configStore,
LocalConfig config, LocalConfig config,
ISettingsLockService settingsLock,
ILogger<LicenseService> logger) ILogger<LicenseService> logger)
{ {
_http = http; _http = http;
_serverBaseUrlProvider = serverBaseUrlProvider; _serverBaseUrlProvider = serverBaseUrlProvider;
_settingsLock = settingsLock;
_configStore = configStore; _configStore = configStore;
_config = config; _config = config;
_logger = logger; _logger = logger;
@@ -87,11 +91,19 @@ public sealed class LicenseService : ILicenseService
_logger.LogInformation("Validating license at {Url} (key {KeyHint}…)", url, licenseKey.Length >= 8 ? licenseKey[..8] : licenseKey); _logger.LogInformation("Validating license at {Url} (key {KeyHint}…)", url, licenseKey.Length >= 8 ? licenseKey[..8] : licenseKey);
// Timeout court (3 s) pour fail-fast en offline. Le HttpClient global a
// Timeout=Infinite, donc sans ce CTS on hangerait jusqu'à ce que l'OS
// abandonne le SYN TCP (~15-21 s sur Windows). Le call site (Refresh
// License pendant Check Updates) catch l'exception et garde le cache
// local, donc fail-fast est sans danger.
using var validateCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
validateCts.CancelAfter(3_000);
using var httpReq = new HttpRequestMessage(HttpMethod.Post, url); using var httpReq = new HttpRequestMessage(HttpMethod.Post, url);
httpReq.Content = JsonContent.Create(req); httpReq.Content = JsonContent.Create(req);
using var resp = await _http.SendAsync(httpReq, ct).ConfigureAwait(false); using var resp = await _http.SendAsync(httpReq, validateCts.Token).ConfigureAwait(false);
var bodyText = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); var bodyText = await resp.Content.ReadAsStringAsync(validateCts.Token).ConfigureAwait(false);
LicenseValidationResponse? parsed; LicenseValidationResponse? parsed;
try try
{ {
@@ -122,6 +134,10 @@ public sealed class LicenseService : ILicenseService
_logger.LogDebug("License response signature OK"); _logger.LogDebug("License response signature OK");
} }
// Propage le hash du mot de passe settings lock vers le service in-memory.
// L'intégrité est garantie par la signature Ed25519 vérifiée juste au-dessus.
_settingsLock.SetPasswordHash(parsed.SettingsLockPasswordHash);
return parsed; return parsed;
} }
@@ -198,13 +214,26 @@ public sealed class LicenseService : ILicenseService
cachedStatus = "expired"; cachedStatus = "expired";
} }
return new LicenseValidationResponse var cached = new LicenseValidationResponse
{ {
Status = cachedStatus, Status = cachedStatus,
OwnerName = _config.License.CachedOwnerName, OwnerName = _config.License.CachedOwnerName,
DownloadEntitlementUntil = _config.License.CachedEntitlementUntil, DownloadEntitlementUntil = _config.License.CachedEntitlementUntil,
ServerTime = lastValidation, ServerTime = lastValidation,
// Restauration des champs channel + canSeeBetas (ajoutés persistants
// depuis v0.27.2). Sans ça, le channelProvider du ManifestService
// retombait sur null à chaque relance, le client fetch sans
// ?channel=, et l'utilisateur ne voyait que les versions default —
// jamais les builds spécifiques à son channel (firefighter, police…).
Channel = _config.License.CachedChannel,
CanSeeBetas = _config.License.CachedCanSeeBetas,
SettingsLockPasswordHash = _config.License.CachedSettingsLockPasswordHash,
}; };
// Propagation au service in-memory : même au démarrage offline, le hash
// cache permet de gater les Settings → Avancés tant qu'on n'a pas pu
// revalider online.
_settingsLock.SetPasswordHash(cached.SettingsLockPasswordHash);
return cached;
} }
public void SaveCached(string licenseKey, LicenseValidationResponse response) public void SaveCached(string licenseKey, LicenseValidationResponse response)
@@ -219,6 +248,9 @@ public sealed class LicenseService : ILicenseService
_config.License.CachedOwnerName = response.OwnerName; _config.License.CachedOwnerName = response.OwnerName;
_config.License.CachedEntitlementUntil = response.DownloadEntitlementUntil; _config.License.CachedEntitlementUntil = response.DownloadEntitlementUntil;
_config.License.CachedStatus = response.Status; _config.License.CachedStatus = response.Status;
_config.License.CachedChannel = response.Channel;
_config.License.CachedCanSeeBetas = response.CanSeeBetas;
_config.License.CachedSettingsLockPasswordHash = response.SettingsLockPasswordHash;
_configStore.Save(_config); _configStore.Save(_config);
} }
@@ -234,7 +266,7 @@ public sealed class LicenseService : ILicenseService
return license.CanDownload(version); return license.CanDownload(version);
} }
public async Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct) public async Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct)
{ {
var key = GetDecryptedKey(); var key = GetDecryptedKey();
if (string.IsNullOrEmpty(key)) return null; if (string.IsNullOrEmpty(key)) return null;
@@ -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. // PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
var encodedKey = Uri.EscapeDataString(key); var encodedKey = Uri.EscapeDataString(key);
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey; var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
// Passe le filename attendu pour disambiguer les channels sur les manifests
// multi-entrée par version. Si null/empty, le serveur retombe sur "1re entrée
// matchant le numéro" (rétro-compat avec les vieux serveurs).
if (!string.IsNullOrWhiteSpace(expectedFilename))
{
url += "&filename=" + Uri.EscapeDataString(expectedFilename);
}
try try
{ {
// Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas
// en 5s, on fallback sur l'URL publique du manifest plutôt que de bloquer
// la UI 100s. Le DL via peer LAN a déjà été tenté avant ce point — si on
// est ici c'est qu'aucun peer n'avait la version.
using var sigCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
sigCts.CancelAfter(5_000);
using var req = new HttpRequestMessage(HttpMethod.Get, url); using var req = new HttpRequestMessage(HttpMethod.Get, url);
using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false); using var resp = await _http.SendAsync(req, sigCts.Token).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
{ {
_logger.LogInformation("Signed URL endpoint returned {Status} — falling back to public URL", (int)resp.StatusCode); _logger.LogInformation("Signed URL endpoint returned {Status} — falling back to public URL", (int)resp.StatusCode);
return null; return null;
} }
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); var body = await resp.Content.ReadAsStringAsync(sigCts.Token).ConfigureAwait(false);
using var doc = JsonDocument.Parse(body); using var doc = JsonDocument.Parse(body);
return doc.RootElement.TryGetProperty("url", out var u) ? u.GetString() : null; return doc.RootElement.TryGetProperty("url", out var u) ? u.GetString() : null;
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Signed URL fetch failed, falling back to public URL"); _logger.LogWarning("Signed URL fetch failed/timeout ({Reason}), falling back to public URL", ex.Message);
return null; return null;
} }
} }
@@ -295,7 +341,11 @@ public sealed class LicenseService : ILicenseService
/// </summary> /// </summary>
public static byte[] CanonicalBytesFor(LicenseValidationResponse response) public static byte[] CanonicalBytesFor(LicenseValidationResponse response)
{ {
// On reconstruit un dictionnaire ordonné comme côté PHP, sans signature // On reconstruit un dictionnaire ordonné comme côté PHP, sans signature.
// ATTENTION : ordre CRITIQUE — Crypto::canonicalJson côté PHP ne trie pas
// les clés, il prend l'ordre du tableau associatif. Toute modif ici doit
// être miroir exact du dictionnaire PHP dans ValidateLicense.php.
// Version courante (v3, depuis v0.28) : inclut settingsLockPasswordHash.
var dict = new Dictionary<string, object?> var dict = new Dictionary<string, object?>
{ {
["status"] = response.Status, ["status"] = response.Status,
@@ -304,6 +354,37 @@ public sealed class LicenseService : ILicenseService
["issuedAt"] = FormatDateAtom(response.IssuedAt), ["issuedAt"] = FormatDateAtom(response.IssuedAt),
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil), ["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
["maxMachines"] = response.MaxMachines, ["maxMachines"] = response.MaxMachines,
["channel"] = response.Channel, // null si default
["canSeeBetas"] = response.CanSeeBetas,
["settingsLockPasswordHash"] = response.SettingsLockPasswordHash, // null si pas de lock
["serverTime"] = FormatDateAtom(response.ServerTime),
};
var opts = new JsonSerializerOptions
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
};
return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
}
/// <summary>
/// Canonical v2 (v0.26 → v0.27) : sans <c>settingsLockPasswordHash</c>.
/// Conservé pour valider les réponses cachées par les anciennes versions
/// du client/serveur. Une fois que toutes les licenses auront été re-validées
/// online avec le nouveau serveur, on pourra retirer cette méthode.
/// </summary>
private static byte[] CanonicalBytesForV2(LicenseValidationResponse response)
{
var dict = new Dictionary<string, object?>
{
["status"] = response.Status,
["licenseId"] = response.LicenseId,
["ownerName"] = response.OwnerName,
["issuedAt"] = FormatDateAtom(response.IssuedAt),
["downloadEntitlementUntil"] = FormatDateAtom(response.DownloadEntitlementUntil),
["maxMachines"] = response.MaxMachines,
["channel"] = response.Channel,
["canSeeBetas"] = response.CanSeeBetas,
["serverTime"] = FormatDateAtom(response.ServerTime), ["serverTime"] = FormatDateAtom(response.ServerTime),
}; };
var opts = new JsonSerializerOptions var opts = new JsonSerializerOptions
@@ -332,8 +413,22 @@ public sealed class LicenseService : ILicenseService
var pkBytes = Convert.FromHexString(publicKeyHex); var pkBytes = Convert.FromHexString(publicKeyHex);
var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey); var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
var sig = Convert.FromBase64String(base64Signature); var sig = Convert.FromBase64String(base64Signature);
var payload = CanonicalBytesFor(response);
return alg.Verify(pk, payload, sig); // Chaîne de fallback canonical pour compat ascendante :
// v3 (current) : avec settingsLockPasswordHash (depuis v0.28)
// v2 : avec channel + canSeeBetas (v0.26 → v0.27)
// legacy : sans aucun de ces champs (avant v0.26)
// Sans ces fallbacks, les licenses cachées par d'anciennes versions
// deviendraient invalides et l'utilisateur serait forcé de revalider
// online — bloqué hors-ligne.
var payloadV3 = CanonicalBytesFor(response);
if (alg.Verify(pk, payloadV3, sig)) return true;
var payloadV2 = CanonicalBytesForV2(response);
if (alg.Verify(pk, payloadV2, sig)) return true;
var payloadLegacy = CanonicalBytesForLegacy(response);
return alg.Verify(pk, payloadLegacy, sig);
} }
catch catch
{ {
@@ -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 ----- // ----- Public key embarquée -----
private static string? TryReadEmbeddedPublicKey() 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 /// session. Tout changement de langue nécessite un redémarrage du launcher
/// (les bindings XAML x:Static ne refresh pas dynamiquement). /// (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. /// Pour Arabic, FlowDirection doit aussi être basculé en RTL côté Window.
/// </summary> /// </summary>
public static class Strings public static class Strings
@@ -27,6 +31,8 @@ public static class Strings
("auto", "Automatique (langue du système)"), ("auto", "Automatique (langue du système)"),
("fr", "Français"), ("fr", "Français"),
("en", "English"), ("en", "English"),
("es", "Español"),
("de", "Deutsch"),
("zh", "中文"), ("zh", "中文"),
("th", "ไทย"), ("th", "ไทย"),
("ar", "العربية"), ("ar", "العربية"),
@@ -39,7 +45,7 @@ public static class Strings
/// </summary> /// </summary>
public static void Init(string? configValue) 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"; var v = configValue?.Trim().ToLowerInvariant() ?? "auto";
if (v == "auto" || string.IsNullOrEmpty(v) || Array.IndexOf(supported, v) < 0) 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 */ } 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, "en" => en,
"es" => es ?? en, // fallback English si pas encore traduit
"de" => de ?? en, // idem
"zh" => zh, "zh" => zh,
"th" => th, "th" => th,
"ar" => ar, "ar" => ar,
@@ -72,43 +90,67 @@ public static class Strings
}; };
// ==================== TOP BAR ==================== // ==================== TOP BAR ====================
public static string TopBarCheckUpdates => T("🔄 Vérifier les MAJ", "🔄 Check for updates", "🔄 检查更新", "🔄 ตรวจสอบการอัปเดต", "🔄 التحقق من التحديثات"); public static string TopBarCheckUpdates => T("🔄 Vérifier les MAJ", "🔄 Check for updates", "🔄 检查更新", "🔄 ตรวจสอบการอัปเดต", "🔄 التحقق من التحديثات", "🔄 Buscar actualizaciones", "🔄 Updates suchen");
public static string TopBarSettings => T("⚙ Paramètres", "⚙ Settings", "⚙ 设置", "⚙ ตั้งค่า", "⚙ الإعدادات"); public static string TopBarSettings => T("⚙ Paramètres", "⚙ Settings", "⚙ 设置", "⚙ ตั้งค่า", "⚙ الإعدادات", "⚙ Ajustes", "⚙ Einstellungen");
// ==================== BODY ==================== // ==================== BODY ====================
public static string BodyVersions => T("Versions", "Versions", "版本", "เวอร์ชัน", "النسخ"); public static string BodyVersions => T("Versions", "Versions", "版本", "เวอร์ชัน", "النسخ", "Versiones", "Versionen");
public static string BodyVersionsHint => T( public static string BodyVersionsHint => T(
"Lance, installe ou supprime une version. Les versions installées et celles disponibles sur le serveur cohabitent.", "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.", "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 FeaturedCurrent => T("VERSION COURANTE", "CURRENT VERSION", "当前版本", "เวอร์ชันปัจจุบัน", "النسخة الحالية", "VERSIÓN ACTUAL", "AKTUELLE VERSION");
public static string SectionOther => T("AUTRES VERSIONS", "OTHER VERSIONS", "其他版本", "เวอร์ชันอื่นๆ", "نسخ أخرى"); public static string SectionOther => T("AUTRES VERSIONS", "OTHER VERSIONS", "其他版本", "เวอร์ชันอื่นๆ", "نسخ أخرى", "OTRAS VERSIONES", "ANDERE VERSIONEN");
public static string ReleasedOn => T("Sortie le ", "Released ", "发布于 ", "เผยแพร่เมื่อ ", "صدر في "); public static string ReleasedOn => T("Sortie le ", "Released ", "发布于 ", "เผยแพร่เมื่อ ", "صدر في ", "Publicado el ", "Veröffentlicht am ");
// ==================== STATUS BADGES ==================== // ==================== STATUS BADGES ====================
public static string StatusInstalled => T("● Installée", "● Installed", "● 已安装", "● ติดตั้งแล้ว", "● مثبتة"); public static string StatusInstalled => T("● Installée", "● Installed", "● 已安装", "● ติดตั้งแล้ว", "● مثبتة", "● Instalada", "● Installiert");
public static string StatusAvailable => T("○ Disponible", "○ Available", "○ 可用", "○ พร้อมใช้งาน", "○ متاحة"); public static string StatusAvailable => T("○ Disponible", "○ Available", "○ 可用", "○ พร้อมใช้งาน", "○ متاحة", "○ Disponible", "○ Verfügbar");
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…"); public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…", "⬇ Descargando…", "⬇ Wird heruntergeladen…");
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…"); public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…", "📦 Instalando…", "📦 Wird installiert…");
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…"); public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…", "🗑 Eliminando…", "🗑 Wird entfernt…");
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…"); 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 ==================== // ==================== ACTIONS ====================
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل"); public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل", "▶ Iniciar", "▶ Starten");
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل"); public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل", "▶ INICIAR", "▶ STARTEN");
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت"); public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت", "⬇ Instalar", "⬇ Installieren");
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت"); public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت", "⬇ INSTALAR", "⬇ INSTALLIEREN");
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء"); public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء", "Cancelar", "Abbrechen");
public static string ActionOk => T("OK", "OK", "确定", "ตกลง", "موافق"); public static string ActionOk => T("OK", "OK", "确定", "ตกลง", "موافق", "OK", "OK");
public static string ActionYes => T("Oui", "Yes", "是", "ใช่", "نعم"); public static string ActionYes => T("Oui", "Yes", "是", "ใช่", "نعم", "Sí", "Ja");
public static string ActionNo => T("Non", "No", "否", "ไม่", "لا"); public static string ActionNo => T("Non", "No", "否", "ไม่", "لا", "No", "Nein");
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً"); public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً", "Más tarde", "Später");
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ"); public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ", "Guardar", "Speichern");
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق"); 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. // 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. // 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. // Tu peux toujours installer/lancer une version antérieure couverte par ta période de license.
@@ -236,6 +278,24 @@ public static class Strings
// ==================== UPDATE ==================== // ==================== UPDATE ====================
public static string UpdateAvailableTitle => T("Mise à jour disponible", "Update available", "有可用更新", "มีการอัปเดต", "تحديث متاح"); public static string UpdateAvailableTitle => T("Mise à jour disponible", "Update available", "有可用更新", "มีการอัปเดต", "تحديث متاح");
public static string UpdateDownload => T("⬇ Télécharger", "⬇ Download", "⬇ 下载", "⬇ ดาวน์โหลด", "⬇ تنزيل"); 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 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 LauncherUpdateAvailable => T("Mise à jour du launcher disponible", "Launcher update available", "启动器更新可用", "มีการอัปเดต Launcher", "تحديث المُشغِّل متاح");
public static string LauncherUpdateBody => T( public static string LauncherUpdateBody => T(
@@ -248,15 +308,25 @@ public static class Strings
public static string LauncherUpdateNow => T("⬇ Mettre à jour", "⬇ Update now", "⬇ 立即更新", "⬇ อัปเดตเดี๋ยวนี้", "⬇ تحديث الآن"); public static string LauncherUpdateNow => T("⬇ Mettre à jour", "⬇ Update now", "⬇ 立即更新", "⬇ อัปเดตเดี๋ยวนี้", "⬇ تحديث الآن");
// ==================== MESSAGEBOX TITLES ==================== // ==================== MESSAGEBOX TITLES ====================
public static string MsgBoxError => T("Erreur", "Error", "错误", "ข้อผิดพลาด", "خطأ"); public static string MsgBoxError => T("Erreur", "Error", "错误", "ข้อผิดพลาด", "خطأ", "Error", "Fehler");
public static string MsgBoxLaunchError => T("Erreur de lancement", "Launch error", "启动错误", "เปิดใช้งานล้มเหลว", "خطأ في التشغيل"); public static string MsgBoxLaunchError => T("Erreur de lancement", "Launch error", "启动错误", "เปิดใช้งานล้มเหลว", "خطأ في التشغيل", "Error al iniciar", "Startfehler");
public static string MsgBoxConfirm => T("Confirmer", "Confirm", "确认", "ยืนยัน", "تأكيد"); public static string MsgBoxConfirm => T("Confirmer", "Confirm", "确认", "ยืนยัน", "تأكيد", "Confirmar", "Bestätigen");
public static string MsgBoxInfo => T("Information", "Information", "信息", "ข้อมูล", "معلومة"); public static string MsgBoxInfo => T("Information", "Information", "信息", "ข้อมูล", "معلومة", "Información", "Information");
public static string MsgBoxPatience => T("Patience", "Please wait", "请稍候", "โปรดรอ", "يرجى الانتظار"); public static string MsgBoxPatience => T("Patience", "Please wait", "请稍候", "โปรดรอ", "يرجى الانتظار", "Por favor espera", "Bitte warten");
public static string MsgBoxLanguageChange => T("Changement de langue", "Language change", "更改语言", "เปลี่ยนภาษา", "تغيير اللغة"); public static string MsgBoxLanguageChange => T("Changement de langue", "Language change", "更改语言", "เปลี่ยนภาษา", "تغيير اللغة", "Cambio de idioma", "Sprachänderung");
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار"); public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار", "Notas de versión", "Versionshinweise");
// ==================== MESSAGEBOX MESSAGES ==================== // ==================== 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( public static string MsgBusy => T(
"Une autre opération est déjà en cours.", "Une autre opération est déjà en cours.",
"Another operation is already in progress.", "Another operation is already in progress.",
@@ -289,6 +359,107 @@ public static class Strings
$"فشل التنزيل أو التثبيت:\n\n{detail}" $"فشل التنزيل أو التثبيت:\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( public static string MsgUninstallFailed(string detail) => T(
$"Suppression échouée : {detail}", $"Suppression échouée : {detail}",
$"Uninstall failed: {detail}", $"Uninstall failed: {detail}",
@@ -422,6 +593,16 @@ public static class Strings
$"تم تثبيت v{version} بنجاح" $"تم تثبيت 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( public static string StatusUninstallingVersion(string version) => T(
$"Suppression v{version}…", $"Suppression v{version}…",
$"Uninstalling v{version}…", $"Uninstalling v{version}…",
@@ -567,6 +748,341 @@ public static class Strings
public static string SettingsDocBackups => SettingsReportBackups; public static string SettingsDocBackups => SettingsReportBackups;
public static string SettingsDocMaxBackups => SettingsReportMaxBackups; 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 ---- // ---- Backups Report tool ----
public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة"); public static string SettingsReportBackups => T("Backups disponibles", "Available backups", "可用备份", "สำรองข้อมูลที่มี", "النسخ الاحتياطية المتاحة");
public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية"); public static string SettingsReportMaxBackups => T("Conserver N backups", "Keep N backups", "保留 N 个备份", "เก็บสำรอง N ชุด", "الاحتفاظ بـ N نسخة احتياطية");

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; 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 public interface IManifestService
{ {
Task<RemoteManifest> FetchAsync(CancellationToken ct); Task<RemoteManifest> FetchAsync(CancellationToken ct);
Task<string> FetchReleaseNotesAsync(string url, 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 System.Text.Json;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using PSLauncher.Core.Lan;
using PSLauncher.Core.Security;
using PSLauncher.Models; using PSLauncher.Models;
namespace PSLauncher.Core.Manifests; namespace PSLauncher.Core.Manifests;
@@ -14,40 +18,218 @@ public sealed class ManifestService : IManifestService
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly Func<string> _serverBaseUrlProvider; 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; 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; _http = http;
_serverBaseUrlProvider = serverBaseUrlProvider; _serverBaseUrlProvider = serverBaseUrlProvider;
_channelProvider = channelProvider;
_manifestCache = manifestCache;
_peerManifestFetcher = peerManifestFetcher;
_settingsLock = settingsLock;
_logger = logger; _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) public async Task<RemoteManifest> FetchAsync(CancellationToken ct)
{ {
var url = TrimSlash(_serverBaseUrlProvider()) + "/manifest"; var isOnline = await IsOnlineAsync(ct).ConfigureAwait(false);
_logger.LogInformation("Fetching manifest: {Url}", url); _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); 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 req.Headers.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue
{ {
NoCache = true, NoCache = true,
NoStore = true, NoStore = true,
}; };
req.Headers.Pragma.ParseAdd("no-cache"); 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(); resp.EnsureSuccessStatusCode();
var manifest = await resp.Content.ReadFromJsonAsync<RemoteManifest>(JsonOptions, ct).ConfigureAwait(false) return await resp.Content.ReadAsStringAsync(ovhCts.Token).ConfigureAwait(false);
?? throw new InvalidOperationException("Empty manifest"); }
return manifest;
/// <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) public async Task<string> FetchReleaseNotesAsync(string url, CancellationToken ct)
{ {
_logger.LogInformation("Fetching release notes: {Url}", url); _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(); 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('/'); private static string TrimSlash(string s) => s.TrimEnd('/');

View File

@@ -79,6 +79,12 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null); 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 using var conn = await OpenAsync(ct).ConfigureAwait(false);
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false); await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
@@ -187,6 +193,57 @@ public sealed class DatabaseMigrationService : IDatabaseMigrationService
return conn; 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) private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
{ {
const string ddl = $""" const string ddl = $"""

View File

@@ -10,6 +10,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageReference Include="Polly" Version="8.4.2" /> <PackageReference Include="Polly" Version="8.4.2" />
<PackageReference Include="NSec.Cryptography" Version="24.4.0" /> <PackageReference Include="NSec.Cryptography" Version="24.4.0" />

View File

@@ -20,14 +20,32 @@ public sealed class ProcessLauncher : IProcessLauncher
{ {
FileName = version.ExecutablePath, FileName = version.ExecutablePath,
WorkingDirectory = version.FolderPath, 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) return SysProcess.Start(psi)
?? throw new InvalidOperationException("Process.Start returned null"); ?? throw new InvalidOperationException("Process.Start returned null");
} }

View File

@@ -50,9 +50,23 @@ public sealed class ReportToolDeployer : IReportToolDeployer
if (!Directory.Exists(cfg.HtdocsRoot)) if (!Directory.Exists(cfg.HtdocsRoot))
{ {
_logger.LogWarning("HtdocsRoot {Root} not found — XAMPP not installed at expected location", cfg.HtdocsRoot); // Crée le dossier htdocs même si XAMPP n'est pas (encore) installé.
return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target, // Cas typique : PC fraichement déployé où XAMPP sera installé après.
$"Le dossier {cfg.HtdocsRoot} est introuvable. XAMPP est-il installé à cet emplacement ? (Settings → Avancé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 stagingTarget = target + ".new";

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

View File

@@ -0,0 +1,120 @@
namespace PSLauncher.Core.SteamVr;
/// <summary>
/// Fusionne le <c>steamvr.vrsettings</c> bundled dans le ZIP PROSERVE
/// (sous-dossier <c>_steamvr/</c>) dans le fichier SteamVR de l'utilisateur.
///
/// Sémantique de merge — SCOPED aux blocs racine <c>system.generated.*</c> :
/// <list type="bullet">
/// <item>SEULS les blocs racine <c>system.generated.*</c> sont considérés.
/// Tous les autres blocs (<c>DesktopUI</c>, <c>GpuSpeed</c>, <c>LastKnown</c>,
/// <c>dashboard</c>, <c>steamvr</c>, <c>trackers</c>, …) sont user-/machine-
/// spécifiques et VARIENT légitimement entre postes — le launcher ne
/// les touche jamais (sinon faux positifs de diff à chaque install).</item>
/// <item>Bloc <c>system.generated.*</c> ABSENT côté cible → push complet du
/// bloc tel quel (les 4 leaf keys fournies par l'installer dans le ZIP).</item>
/// <item>Bloc PRÉSENT côté cible → réécriture des leaf keys
/// <c>*_CurrentURL_openxr</c> et <c>*_PreviousURL_openxr</c> uniquement.
/// Les autres leaf keys (<c>_AutosaveURL_openxr</c>,
/// <c>_NeedToUpdateAutosave_openxr</c>) restent intactes — gérées par
/// SteamVR.</item>
/// <item>La décision "merge nécessaire" repose UNIQUEMENT sur la diff des
/// CurrentURL/PreviousURL (ou l'absence du bloc). Pas de popup si l'install
/// est ré-effectué et que ces valeurs n'ont pas bougé.</item>
/// </list>
///
/// Tue préalablement SteamVR + Vive Business Streaming pour éviter qu'ils ne
/// rétablissent leurs valeurs en mémoire au prochain flush vers disque
/// (Vive Business Streaming en premier car il relance SteamVR sinon).
/// </summary>
public interface ISteamVrSettingsDeployer
{
/// <summary>
/// Dry-run : détermine si un merge réel modifierait la cible. Permet à
/// MainViewModel de skipper la phase entière (popup + kill SteamVR/VBS +
/// merge) quand la cible est déjà à jour. <b>Aucun processus tué, aucun
/// fichier touché.</b> Bénéfice opérationnel : sur un PC où la conf SteamVR
/// est déjà conforme à la release courante, la ré-install ne dérange pas
/// l'opérateur ni les sessions VR éventuellement en cours.
/// </summary>
Task<SteamVrMergeCheckResult> CheckMergeNeededAsync(
string installFolder,
CancellationToken ct);
/// <param name="processesToKill">
/// Liste explicite des noms de processus à terminer avant le merge.
/// MainViewModel construit cette liste depuis les health checks de type
/// "Process" (= les processus que l'opérateur surveille déjà = ceux qui
/// risquent de garder un handle sur le fichier SteamVR ou de relancer
/// SteamVR). VBS doit y être en tête. Si null ou vide, on retombe sur
/// la liste par défaut de <see cref="SteamVrConfig.ProcessesToKill"/>.
/// </param>
Task<SteamVrMergeResult> MergeAsync(
string installFolder,
IReadOnlyList<string>? processesToKill,
CancellationToken ct);
}
/// <summary>
/// Résultat du dry-run <see cref="ISteamVrSettingsDeployer.CheckMergeNeededAsync"/>.
/// </summary>
public enum SteamVrMergeCheckOutcome
{
/// <summary>La cible contient déjà toutes les clés du source avec les
/// mêmes valeurs (deep equality) — aucun changement ne serait apporté.
/// MainViewModel doit skipper la phase sans déranger l'opérateur.</summary>
UpToDate,
/// <summary>Le merge apporterait au moins un changement (clé ajoutée ou
/// valeur remplacée) — MainViewModel doit proposer le pop-up et procéder.</summary>
Needed,
/// <summary>Pas de <c>_steamvr/steamvr.vrsettings</c> dans le ZIP — rien à faire.</summary>
NoSourceFile,
/// <summary>Option <c>AutoMerge=false</c> dans la config — l'opérateur l'a désactivé.</summary>
DisabledByConfig,
/// <summary>Steam pas détecté sur la machine.</summary>
SteamNotFound,
/// <summary>JSON source ou cible invalide. MainViewModel peut log warn.</summary>
InvalidJson,
}
/// <param name="Outcome">État global du check.</param>
/// <param name="TargetPath">Chemin résolu vers <c>steamvr.vrsettings</c>. Non-null
/// si Steam a été localisé, même si le fichier n'existe pas encore.</param>
/// <param name="RootKeysWouldChange">Nombre de clés racine qui seraient ajoutées
/// ou modifiées. <c>0</c> ssi <see cref="Outcome"/> est <see cref="SteamVrMergeCheckOutcome.UpToDate"/>.
/// Affiché dans le popup pour donner du contexte à l'opérateur.</param>
/// <param name="ErrorMessage">Détail technique en cas d'<see cref="SteamVrMergeCheckOutcome.InvalidJson"/>.</param>
public sealed record SteamVrMergeCheckResult(
SteamVrMergeCheckOutcome Outcome,
string? TargetPath,
int RootKeysWouldChange,
string? ErrorMessage = null);
public enum SteamVrMergeStatus
{
/// <summary>Pas de <c>_steamvr/steamvr.vrsettings</c> dans le ZIP, no-op silencieux.</summary>
SkippedNoSourceFile,
/// <summary>Option <c>SteamVrConfig.AutoMerge=false</c>, l'opérateur l'a explicitement désactivé.</summary>
SkippedDisabledByConfig,
/// <summary>Steam introuvable (ni via registre ni au path par défaut).</summary>
SteamNotFound,
/// <summary>Le fichier source ou cible n'est pas un JSON valide.</summary>
InvalidJson,
/// <summary>Tout s'est bien passé.</summary>
Merged,
/// <summary>Erreur I/O ou autre exception non récupérable.</summary>
Failed,
}
/// <param name="KeysReplaced">Nombre de clés racine qui existaient déjà côté cible (overwritées).</param>
/// <param name="KeysAdded">Nombre de clés racine ajoutées (n'existaient pas côté cible).</param>
/// <param name="TargetPath">Chemin du fichier modifié. Null si le merge a été skippé.</param>
/// <param name="ProcessesKilled">Nombre total de processus tués avant le merge.</param>
/// <param name="ErrorMessage">Détail technique en cas de Failed / InvalidJson.</param>
public sealed record SteamVrMergeResult(
SteamVrMergeStatus Status,
int KeysReplaced,
int KeysAdded,
string? TargetPath,
int ProcessesKilled,
string? ErrorMessage = null);

View File

@@ -0,0 +1,622 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using PSLauncher.Models;
using SysProcess = System.Diagnostics.Process;
namespace PSLauncher.Core.SteamVr;
/// <summary>
/// Implémentation du merge SteamVR. Voir <see cref="ISteamVrSettingsDeployer"/>.
/// </summary>
public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
{
/// <summary>Sous-dossier dans le ZIP PROSERVE où chercher le fichier source.</summary>
private const string SourceFolderName = "_steamvr";
/// <summary>Nom canonique du fichier de settings SteamVR (côté source ET cible).</summary>
private const string SettingsFileName = "steamvr.vrsettings";
/// <summary>
/// Pattern d'identification d'un bloc auto-généré par SteamVR pour une
/// application OpenXR donnée. Ex :
/// <c>system.generated.openxr.proserve_ue_5_5.proserve_ue_5_5.exe</c>.
///
/// Le launcher ne considère QUE ces blocs pour la diff + le push. Tous les
/// autres blocs racine du source (<c>DesktopUI</c>, <c>GpuSpeed</c>,
/// <c>LastKnown</c>, <c>dashboard</c>, <c>steamvr</c>, <c>trackers</c>, …)
/// sont user-/machine-spécifiques et VARIENT légitimement entre postes —
/// les checker provoquerait des faux positifs de diff à chaque install.
/// </summary>
private static readonly Regex SystemGeneratedKey =
new(@"^system\.generated\.", RegexOptions.Compiled);
/// <summary>
/// Leaf keys dans un bloc <c>system.generated.*</c> qui pilotent la diff.
/// Si l'une ou l'autre diffère entre source et target → le bloc est marqué
/// "à mettre à jour". Les autres leaf keys (<c>_AutosaveURL_openxr</c>,
/// <c>_NeedToUpdateAutosave_openxr</c>) ne sont PAS considérées pour la
/// décision de diff, mais elles sont quand même écrites lors du push initial
/// d'un bloc absent du target.
/// </summary>
private static bool IsRelevantOpenXrLeaf(string leafKey) =>
leafKey.EndsWith("_CurrentURL_openxr", StringComparison.Ordinal)
|| leafKey.EndsWith("_PreviousURL_openxr", StringComparison.Ordinal);
/// <summary>
/// Délai d'attente entre le kill des processus et l'ouverture du fichier.
/// SteamVR garde un handle sur steamvr.vrsettings et le flush ses changements
/// au shutdown ⇒ on attend que le process se termine vraiment avant de toucher
/// au fichier. 1 s suffit en pratique mais 2 s pour avoir une marge.
/// </summary>
private static readonly TimeSpan PostKillSettleDelay = TimeSpan.FromSeconds(2);
private readonly Func<SteamVrConfig> _configProvider;
private readonly ILogger<SteamVrSettingsDeployer> _logger;
public SteamVrSettingsDeployer(
Func<SteamVrConfig> configProvider,
ILogger<SteamVrSettingsDeployer> logger)
{
_configProvider = configProvider;
_logger = logger;
}
public async Task<SteamVrMergeCheckResult> CheckMergeNeededAsync(
string installFolder,
CancellationToken ct)
{
var config = _configProvider();
// 1) Fichier source présent ?
var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName);
if (!File.Exists(sourcePath))
{
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.NoSourceFile, null, 0);
}
// 2) Option activée ?
if (!config.AutoMerge)
{
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.DisabledByConfig, null, 0);
}
// 3) Cible localisée ?
var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride)
? config.SettingsFilePathOverride!
: FindSteamVrSettingsPath();
if (targetPath is null)
{
return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.SteamNotFound, null, 0);
}
// 4) Parse source. Si invalide, le vrai merge échouera de toute façon.
JsonObject sourceObj;
try
{
var sourceText = await File.ReadAllTextAsync(sourcePath, ct);
sourceObj = JsonNode.Parse(sourceText) as JsonObject
?? throw new InvalidDataException("source root is not a JSON object");
}
catch (Exception ex)
{
return new SteamVrMergeCheckResult(
SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0,
$"Source JSON invalide : {ex.Message}");
}
// 5) Si la cible n'existe pas, tout est à ajouter — merge nécessaire.
if (!File.Exists(targetPath))
{
_logger.LogInformation(
"SteamVR target file does not exist yet ({Path}) — merge needed (will create from scratch)",
targetPath);
return new SteamVrMergeCheckResult(
SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count);
}
// 6) Parse cible. Si invalide, on signale mais le vrai merge serait
// contraint d'écrire (= overwrite) une cible illisible.
JsonObject targetObj;
try
{
var targetText = await File.ReadAllTextAsync(targetPath, ct);
if (string.IsNullOrWhiteSpace(targetText))
{
// Fichier vide ⇒ merge ajouterait tout le source.
return new SteamVrMergeCheckResult(
SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count);
}
targetObj = JsonNode.Parse(targetText) as JsonObject
?? throw new InvalidDataException("target root is not a JSON object");
}
catch (Exception ex)
{
return new SteamVrMergeCheckResult(
SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0,
$"Cible JSON invalide : {ex.Message}");
}
// 7) Diff scoped : SEULS les blocs racine `system.generated.*` sont
// considérés. Les autres (DesktopUI, GpuSpeed, LastKnown, dashboard,
// steamvr, trackers, …) sont user-/machine-spécifiques et varient
// légitimement entre postes — les checker générerait des faux positifs
// de diff à chaque install. Cf. SystemGeneratedKey.
//
// Pour chaque bloc system.generated.* :
// • ABSENT côté target → on push le bloc entier au merge (= compte
// comme un "change")
// • PRÉSENT côté target → on compare uniquement les leaf keys
// `*_CurrentURL_openxr` et `*_PreviousURL_openxr`. Si l'une diffère,
// c'est un "change" (l'opérateur a édité ses bindings). Sinon, skip.
int rootKeysChanging = 0;
foreach (var (key, srcValue) in sourceObj)
{
// Whitelist : on n'examine QUE les blocs system.generated.*
if (!SystemGeneratedKey.IsMatch(key)) continue;
if (!targetObj.ContainsKey(key))
{
rootKeysChanging++;
continue;
}
var tgtValue = targetObj[key];
if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
{
if (OpenXrBindingUrlsDiffer(tgtChild, srcChild))
rootKeysChanging++;
}
}
var outcome = rootKeysChanging > 0
? SteamVrMergeCheckOutcome.Needed
: SteamVrMergeCheckOutcome.UpToDate;
_logger.LogInformation(
"SteamVR merge check: outcome={Outcome}, rootKeysChanging={Count}, target={Path}",
outcome, rootKeysChanging, targetPath);
return new SteamVrMergeCheckResult(outcome, targetPath, rootKeysChanging);
}
public async Task<SteamVrMergeResult> MergeAsync(
string installFolder,
IReadOnlyList<string>? processesToKill,
CancellationToken ct)
{
var config = _configProvider();
// 1. Cherche le fichier source dans le ZIP extrait. Pas de fichier ⇒
// pas de merge ⇒ silent skip (toutes les versions PROSERVE ne shippent
// pas forcément un _steamvr/).
var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName);
if (!File.Exists(sourcePath))
{
_logger.LogDebug("No _steamvr/{File} in {Folder}, skipping SteamVR merge",
SettingsFileName, installFolder);
return new SteamVrMergeResult(
SteamVrMergeStatus.SkippedNoSourceFile, 0, 0, null, 0);
}
// 2. Opt-out par config (peut être désactivé sur des machines de dev).
if (!config.AutoMerge)
{
_logger.LogInformation("SteamVR merge disabled by config (AutoMerge=false)");
return new SteamVrMergeResult(
SteamVrMergeStatus.SkippedDisabledByConfig, 0, 0, null, 0);
}
// 3. Résout le path du fichier cible (override config OU registre OU fallback).
var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride)
? config.SettingsFilePathOverride!
: FindSteamVrSettingsPath();
if (targetPath is null)
{
_logger.LogWarning(
"Steam install not found via registry + fallback, cannot merge SteamVR settings");
return new SteamVrMergeResult(
SteamVrMergeStatus.SteamNotFound, 0, 0, null, 0,
"Steam introuvable (registre HKCU\\Software\\Valve\\Steam + HKLM\\Wow6432Node + %ProgramFiles(x86)%\\Steam)");
}
try
{
// 4. Tue SteamVR + Vive Business Streaming. Vive en PREMIER car il
// relance auto SteamVR sinon. On parse + write avec un délai de
// règlement APRES, parce que les processus flushent leurs settings
// au shutdown.
//
// Le caller (MainViewModel) construit la liste depuis les health
// checks de type Process (= les programmes que l'opérateur surveille
// déjà). Si rien n'est passé, on retombe sur la liste par défaut
// de la config — utile pour les setups sans health checks configurés.
var killList = (processesToKill is not null && processesToKill.Count > 0)
? processesToKill
: config.ProcessesToKill;
var killed = await KillProcessesAsync(killList, ct);
if (killed > 0)
{
_logger.LogInformation(
"Killed {Count} SteamVR/VBS process(es), waiting {Delay}ms for file release",
killed, (int)PostKillSettleDelay.TotalMilliseconds);
await Task.Delay(PostKillSettleDelay, ct);
}
// 5. Parse le JSON source. Si le fichier est invalide, on log et on
// abandonne — pas la peine de risquer un overwrite avec une cible
// potentiellement valide.
JsonObject sourceObj;
try
{
var sourceText = await File.ReadAllTextAsync(sourcePath, ct);
sourceObj = JsonNode.Parse(sourceText) as JsonObject
?? throw new InvalidDataException("source root is not a JSON object");
}
catch (Exception ex)
{
_logger.LogError(ex, "Source SteamVR settings file is not valid JSON: {Path}", sourcePath);
return new SteamVrMergeResult(
SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed,
$"Source JSON invalide : {ex.Message}");
}
// 6. Parse le JSON cible (si absent ou vide → on part d'un objet
// vide, on créera le fichier from scratch). Si le fichier existe
// mais est invalide, on abandonne (mieux vaut un fichier intact
// que de l'écraser).
JsonObject targetObj;
if (File.Exists(targetPath))
{
try
{
var targetText = await File.ReadAllTextAsync(targetPath, ct);
if (string.IsNullOrWhiteSpace(targetText))
{
targetObj = new JsonObject();
}
else
{
targetObj = JsonNode.Parse(targetText) as JsonObject
?? throw new InvalidDataException("target root is not a JSON object");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Target SteamVR settings file is not valid JSON: {Path}", targetPath);
return new SteamVrMergeResult(
SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed,
$"Cible JSON invalide : {ex.Message}");
}
}
else
{
_logger.LogInformation(
"Target SteamVR settings file does not exist yet, creating: {Path}",
targetPath);
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
targetObj = new JsonObject();
}
// 7. Deep merge récursif :
// • Clé absente côté cible → ajoute (copie complète du source)
// • Clé présente des DEUX côtés ET les deux valeurs sont des objets
// JSON → recurse (préserve les sous-clés cible non touchées par le source)
// • Sinon (types différents, ou tableau, ou primitive) → le source gagne
// (full replace de la valeur)
//
// Cas concret du bloc "trackers" : si le fichier user contient déjà
// "trackers" : { "/devices/oculus/.../X" : "TrackerRole_...",
// "/devices/htc/TKR_LEFT" : "ancienne valeur" }
// et que le source apporte
// "trackers" : { "/devices/htc_business_streaming/TKR_LEFT" : "...",
// "/devices/htc_business_streaming/TKR_RIGHT" : "..." }
// → résultat fusionné = les 3 clés oculus + 2 htc_business_streaming
// (oculus préservée, ancienne htc/TKR_LEFT préservée puisque clé
// différente, htc_business_streaming/* ajoutées).
//
// Les compteurs top-level reflètent uniquement les clés RACINE
// (remplacées ou ajoutées) parce que c'est le niveau visible dans
// les logs et le seul niveau que l'opérateur a besoin de monitorer.
// Merge scoped : on n'agit QUE sur les blocs racine system.generated.*
// (cf. justification dans CheckMergeNeededAsync).
// • ABSENT côté target → push complet du bloc tel quel
// (= ce que l'installer fournit, avec ses 4 leaf keys)
// • PRÉSENT côté target → écrase les leaf keys CurrentURL +
// PreviousURL avec les valeurs du source. Les autres leaf keys
// (AutosaveURL, NeedToUpdate) restent comme côté target — gérées
// par SteamVR, pas touchées par le launcher.
int replaced = 0, added = 0;
foreach (var (key, srcValue) in sourceObj.ToList())
{
if (!SystemGeneratedKey.IsMatch(key)) continue;
if (!targetObj.ContainsKey(key))
{
// Bloc absent côté target → push complet du source
// (les 4 leaf keys, telles que fournies par l'installer).
targetObj[key] = srcValue?.DeepClone();
added++;
continue;
}
if (srcValue is JsonObject srcChild
&& targetObj[key] is JsonObject tgtChild)
{
// Bloc présent → on n'écrit QUE les leaf keys CurrentURL +
// PreviousURL depuis source (replace). Si elles matchent déjà,
// c'est un no-op au final (mais on tag quand même replaced
// car le check a déclenché un merge).
int leafTouched = ReplaceOpenXrBindingUrls(tgtChild, srcChild);
if (leafTouched > 0) replaced++;
}
}
// 8. Write atomique via .tmp + rename. SteamVR utilise un JSON
// indenté à 3 espaces dans ses propres écritures — on respecte le
// style pour minimiser le diff visuel pour un opérateur qui ouvre
// le fichier en éditeur.
var json = targetObj.ToJsonString(new JsonSerializerOptions
{
WriteIndented = true,
});
var tmpPath = targetPath + ".pslauncher-tmp";
await File.WriteAllTextAsync(tmpPath, json, ct);
// File.Move(overwrite=true) est atomique sur NTFS (.NET 5+)
File.Move(tmpPath, targetPath, overwrite: true);
_logger.LogInformation(
"SteamVR settings merge OK: replaced={Replaced}, added={Added}, target={Path}",
replaced, added, targetPath);
return new SteamVrMergeResult(
SteamVrMergeStatus.Merged, replaced, added, targetPath, killed);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "SteamVR merge failed unexpectedly");
return new SteamVrMergeResult(
SteamVrMergeStatus.Failed, 0, 0, targetPath, 0,
ex.Message);
}
}
/// <summary>
/// Path canonique de Steam x86 quand il est installé à l'emplacement
/// standard (90 % des cas). Exposé pour que la UI Settings puisse afficher
/// la valeur attendue en placeholder du TextBox d'override.
/// </summary>
public static string CanonicalSettingsPath { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
"Steam", "config", "steamvr.vrsettings");
/// <summary>
/// Résout le path vers <c>steamvr.vrsettings</c>. Stratégie : on essaie les
/// 3 sources dans l'ordre, et on retourne la PREMIÈRE qui pointe vers un
/// dossier Steam qui existe vraiment sur disque. Le fichier lui-même peut
/// ne pas exister encore — on le créera au write.
/// <list type="number">
/// <item>HKCU\Software\Valve\Steam → SteamPath (valeur typique : <c>C:/Program Files (x86)/Steam</c>)</item>
/// <item>HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath (typique : <c>C:\Program Files (x86)\Steam</c>)</item>
/// <item>Fallback hardcodé : <c>%ProgramFiles(x86)%\Steam\config\steamvr.vrsettings</c></item>
/// </list>
/// Retourne null si AUCUNE des 3 sources ne pointe vers un dossier Steam
/// existant — Steam n'est probablement pas installé sur cette machine.
/// </summary>
private string? FindSteamVrSettingsPath()
{
// Énumère les candidats dans l'ordre de priorité. Pour chacun on
// vérifie que la racine Steam (= grand-parent du fichier final) existe
// physiquement avant d'accepter — sinon un opérateur qui a désinstallé
// Steam puis ré-installé ailleurs avait des fantômes du registre qui
// nous renvoyaient vers un dossier inexistant.
var candidates = new (string Path, string Source)[]
{
(TryRegistryPath(Registry.CurrentUser, @"Software\Valve\Steam", "SteamPath"),
"HKCU\\Software\\Valve\\Steam"),
(TryRegistryPath(Registry.LocalMachine, @"SOFTWARE\WOW6432Node\Valve\Steam", "InstallPath"),
"HKLM\\Wow6432Node\\Valve\\Steam"),
(CanonicalSettingsPath, "fallback %ProgramFiles(x86)%\\Steam"),
};
foreach (var (candidate, source) in candidates)
{
if (string.IsNullOrWhiteSpace(candidate)) continue;
// grand-parent = .../Steam (parent = .../Steam/config)
var steamRoot = Path.GetDirectoryName(Path.GetDirectoryName(candidate));
if (steamRoot is null || !Directory.Exists(steamRoot))
{
_logger.LogDebug(
"Skipping SteamVR candidate from {Source} (Steam root {Root} doesn't exist)",
source, steamRoot);
continue;
}
_logger.LogInformation(
"Resolved SteamVR settings target via {Source}: {Path} (file {Exists})",
source, candidate, File.Exists(candidate) ? "exists" : "will be created");
return candidate;
}
return null;
}
/// <summary>
/// Lit une string sous une clé registre + sous-clé nommée, normalise les
/// slashes Unix → Windows, puis compose le path vers <c>config\{SettingsFileName}</c>.
/// Retourne string.Empty si la clé n'existe pas ou si la valeur est vide.
/// Wrappé en try/catch silencieux : un manque de droits sur HKLM n'est pas
/// fatal — on tombera sur le candidat suivant.
/// </summary>
private string TryRegistryPath(RegistryKey root, string subKey, string valueName)
{
try
{
using var key = root.OpenSubKey(subKey);
if (key?.GetValue(valueName) is string raw && !string.IsNullOrWhiteSpace(raw))
{
var normalized = raw.Replace('/', '\\').TrimEnd('\\');
return Path.Combine(normalized, "config", SettingsFileName);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Registry read failed for {Root}\\{SubKey}!{Value}",
root.Name, subKey, valueName);
}
return string.Empty;
}
/// <summary>
/// Tue tous les processus dont le nom (sans .exe) correspond à un des
/// patterns de <paramref name="processNames"/> (case-insensitive). Best-effort :
/// si on ne peut pas killer un process (AccessDenied, déjà parti), on log
/// et on continue. Retourne le nombre total de process effectivement tués.
///
/// L'ordre des entrées dans la liste compte : Vive Business Streaming
/// doit être en TÊTE car il relance SteamVR sinon.
/// </summary>
/// <summary>
/// Comparaison d'égalité structurelle entre deux JsonNode (objects, arrays,
/// primitives). System.Text.Json n'expose pas <c>DeepEquals</c> avant .NET 9 ;
/// on l'implémente à la main. Pour les primitives, on compare via la
/// serialization JSON par défaut — ToJsonString produit un format canonique
/// pour chaque type (number, string, bool, null), donc deux valeurs
/// logiquement égales produiront la même chaîne.
/// </summary>
private static bool JsonNodesEqual(JsonNode? a, JsonNode? b)
{
if (a is null && b is null) return true;
if (a is null || b is null) return false;
if (a is JsonObject aObj && b is JsonObject bObj)
{
if (aObj.Count != bObj.Count) return false;
foreach (var (key, aVal) in aObj)
{
if (!bObj.TryGetPropertyValue(key, out var bVal)) return false;
if (!JsonNodesEqual(aVal, bVal)) return false;
}
return true;
}
if (a is JsonArray aArr && b is JsonArray bArr)
{
if (aArr.Count != bArr.Count) return false;
for (int i = 0; i < aArr.Count; i++)
{
if (!JsonNodesEqual(aArr[i], bArr[i])) return false;
}
return true;
}
if (a is JsonValue && b is JsonValue)
{
// Primitives : la sérialisation canonique gère uniformément les
// numbers (int/long/double), strings, bools, null.
return string.Equals(a.ToJsonString(), b.ToJsonString(), StringComparison.Ordinal);
}
// Types incompatibles (ex: object vs primitive)
return false;
}
/// <summary>
/// Dry-run pour un bloc <c>system.generated.*</c> déjà présent côté target.
/// Retourne true si une des leaf keys pertinentes (<c>_CurrentURL_openxr</c>
/// ou <c>_PreviousURL_openxr</c>) DIFFÈRE entre source et target. Les autres
/// leaf keys (AutosaveURL, NeedToUpdate, …) sont ignorées pour la décision.
/// </summary>
private static bool OpenXrBindingUrlsDiffer(JsonObject target, JsonObject source)
{
foreach (var (leafKey, srcValue) in source)
{
if (!IsRelevantOpenXrLeaf(leafKey)) continue;
if (!target.TryGetPropertyValue(leafKey, out var tgtValue)) return true; // absent du target
if (!JsonNodesEqual(tgtValue, srcValue)) return true; // valeur différente
}
return false;
}
/// <summary>
/// Merge effectif sur un bloc <c>system.generated.*</c> déjà présent côté
/// target. Réécrit les leaf keys CurrentURL et PreviousURL depuis le source,
/// laisse les autres leaf keys (<c>_AutosaveURL_openxr</c>,
/// <c>_NeedToUpdateAutosave_openxr</c>) intactes — c'est SteamVR qui les
/// gère, le launcher ne s'en mêle pas.
/// Retourne le nombre de leaf keys effectivement touchées (= 0 si source et
/// target ont déjà les mêmes URLs, sinon &gt; 0).
/// </summary>
private static int ReplaceOpenXrBindingUrls(JsonObject target, JsonObject source)
{
int touched = 0;
foreach (var (leafKey, leafValue) in source.ToList())
{
if (!IsRelevantOpenXrLeaf(leafKey)) continue;
// Skip si target a déjà la même valeur exacte (no-op silencieux,
// pas la peine de marquer touched).
if (target.TryGetPropertyValue(leafKey, out var existing)
&& JsonNodesEqual(existing, leafValue))
{
continue;
}
target[leafKey] = leafValue?.DeepClone();
touched++;
}
return touched;
}
private async Task<int> KillProcessesAsync(
IReadOnlyList<string> processNames,
CancellationToken ct)
{
int total = 0;
foreach (var name in processNames)
{
ct.ThrowIfCancellationRequested();
var trimmed = name?.Trim();
if (string.IsNullOrEmpty(trimmed)) continue;
// GetProcessesByName attend le nom SANS le .exe ; un opérateur peut
// l'avoir saisi avec → strip défensif.
if (trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
trimmed = trimmed[..^4];
SysProcess[] procs;
try { procs = SysProcess.GetProcessesByName(trimmed); }
catch (Exception ex)
{
_logger.LogDebug(ex, "GetProcessesByName({Name}) failed", trimmed);
continue;
}
foreach (var p in procs)
{
try
{
if (p.HasExited) { p.Dispose(); continue; }
_logger.LogInformation("Killing process {Name} (PID {Pid})", p.ProcessName, p.Id);
p.Kill(entireProcessTree: true);
// Wait un peu pour que le process release ses handles
try { await p.WaitForExitAsync(ct); }
catch (OperationCanceledException) { throw; }
catch { /* race avec Kill possible, ok */ }
total++;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex,
"Could not kill process {Name} (PID {Pid}) — continuing",
p.ProcessName, p.Id);
}
finally
{
try { p.Dispose(); } catch { }
}
}
}
return total;
}
}

View File

@@ -4,7 +4,14 @@ namespace PSLauncher.Core.Updates;
public interface IUpdateChecker public interface IUpdateChecker
{ {
Task<UpdateCheckResult> CheckAsync(CancellationToken ct); /// <param name="canSeeBetas">
/// Si false, les versions marquées <see cref="VersionManifest.IsBeta"/> sont
/// exclues de la sélection du <c>LatestRemote</c> — pas de popup « MAJ dispo »
/// pour une beta que le client ne pourrait de toute façon pas voir dans la
/// liste après filtrage license côté <c>MainViewModel.RebuildList</c>. Le
/// manifest complet est quand même retourné pour le rendu ultérieur.
/// </param>
Task<UpdateCheckResult> CheckAsync(bool canSeeBetas, CancellationToken ct);
} }
public sealed record UpdateCheckResult( public sealed record UpdateCheckResult(

View File

@@ -21,29 +21,56 @@ public sealed class UpdateChecker : IUpdateChecker
_logger = logger; _logger = logger;
} }
public async Task<UpdateCheckResult> CheckAsync(CancellationToken ct) public async Task<UpdateCheckResult> CheckAsync(bool canSeeBetas, CancellationToken ct)
{ {
try try
{ {
var manifest = await _manifestService.FetchAsync(ct).ConfigureAwait(false); var manifest = await _manifestService.FetchAsync(ct).ConfigureAwait(false);
// On ignore le champ `manifest.Latest` (trop facile à oublier au serveur). // On ignore le champ `manifest.Latest` (trop facile à oublier au serveur).
// On prend toujours la plus haute version SemVer disponible et téléchargeable. // On prend toujours la plus haute version disponible et téléchargeable, en
var latest = manifest.Versions // ordonnant via VersionOrder — ça respecte la règle « non-beta > beta au
.Where(v => v.AvailableForDownload) // même préfixe 3-digit » (ex : 1.5.4 non-beta > 1.5.4.32 beta), sinon
.OrderByDescending(v => SemVer.Parse(v.Version)) // c'est du SemVer strict.
//
// Filtre isBeta : les clients sans droits beta ne doivent JAMAIS voir une
// beta comme LatestRemote — sinon le popup « MAJ dispo » leur propose une
// version qu'ils ne pourront pas installer (filtrée en aval par RebuildList).
// Le filtre est appliqué avant le tri VersionOrder pour éviter qu'une beta
// ne se glisse comme "latest" chez un client non-autorisé, même si elle
// aurait été détrônée par une non-beta au même préfixe 3-digit.
var candidates = manifest.Versions.Where(v => v.AvailableForDownload);
if (!canSeeBetas) candidates = candidates.Where(v => !v.IsBeta);
var latest = candidates
.OrderByDescending(v => v, Comparer<VersionManifest>.Create(VersionOrder.Compare))
.FirstOrDefault(); .FirstOrDefault();
var installed = _registry.Scan(); var installed = _registry.Scan();
// Pour les installs locales on ne connaît pas leur statut beta d'origine
// (l'entrée manifest a pu disparaître entre-temps). On les compare en
// SemVer pur — c'est acceptable parce que le vrai check « faut-il updater »
// ci-dessous confronte l'install LA PLUS HAUTE au meilleur remote, qui
// lui embarque son isBeta.
var latestInstalled = installed var latestInstalled = installed
.OrderByDescending(v => SemVer.Parse(v.Version)) .OrderByDescending(v => SemVer.Parse(v.Version))
.FirstOrDefault(); .FirstOrDefault();
var isLatestInstalled = latest is not null && latestInstalled is not null var isLatestInstalled = latest is not null && latestInstalled is not null
&& latestInstalled.Version == latest.Version; && latestInstalled.Version == latest.Version;
// Pour « isNewer », on doit décider si `latest` (remote) supersede
// `latestInstalled` (local). On cherche l'entrée manifest correspondante
// à l'install locale pour retrouver son isBeta d'origine. Si absente
// (typiquement : install locale d'une ancienne beta que l'opérateur a
// supprimée du manifest), on la traite comme non-beta pour ne pas
// fausser la comparaison en défaveur de l'install locale.
var installedRemote = latestInstalled is null
? null
: manifest.Versions.FirstOrDefault(v => v.Version == latestInstalled.Version);
var installedIsBeta = installedRemote?.IsBeta ?? false;
var isNewer = latest is not null var isNewer = latest is not null
&& (latestInstalled is null && (latestInstalled is null
|| SemVer.Parse(latest.Version).CompareTo(SemVer.Parse(latestInstalled.Version)) > 0); || VersionOrder.Compare(latest.Version, latest.IsBeta,
latestInstalled.Version, installedIsBeta) > 0);
return new UpdateCheckResult(manifest, latest, latestInstalled, isLatestInstalled, isNewer, null); return new UpdateCheckResult(manifest, latest, latestInstalled, isLatestInstalled, isNewer, null);
} }

View File

@@ -1,8 +1,14 @@
namespace PSLauncher.Models; namespace PSLauncher.Models;
/// <param name="EntryId">
/// Id de l'entrée manifest source, lu depuis <c>.proserve-meta.json</c>. Null
/// pour les installs antérieurs à v1.0.8 (rétro-compat). Sert au matching
/// installed ↔ remote quand deux entries partagent un folder name résolu.
/// </param>
public sealed record InstalledVersion( public sealed record InstalledVersion(
string Version, string Version,
string FolderPath, string FolderPath,
string ExecutablePath, string ExecutablePath,
DateTime InstalledAt, DateTime InstalledAt,
long SizeBytes); long SizeBytes,
string? EntryId = null);

View File

@@ -22,6 +22,33 @@ public sealed class LicenseValidationResponse
[JsonPropertyName("maxMachines")] [JsonPropertyName("maxMachines")]
public int? MaxMachines { get; set; } public int? MaxMachines { get; set; }
/// <summary>
/// Channel de manifest associé à la license. NULL = manifest default
/// (versions.json). Sinon le launcher fetch versions-{Channel}.json. Le
/// champ est inclus dans la signature Ed25519, donc un user ne peut pas
/// modifier sa réponse cachée pour basculer sur un autre channel.
/// </summary>
[JsonPropertyName("channel")]
public string? Channel { get; set; }
/// <summary>
/// True si la license autorise l'affichage des versions taggées
/// <c>isBeta=true</c> dans le launcher. Le filtrage est côté client (le
/// serveur peut servir le manifest complet — la signature Ed25519
/// protège contre toute modif locale du flag).
/// </summary>
[JsonPropertyName("canSeeBetas")]
public bool CanSeeBetas { get; set; }
/// <summary>
/// Hash SHA-256 (hex lowercase) du mot de passe qui verrouille les Settings
/// avancés du launcher. NULL = pas de verrouillage. Défini par-license dans
/// le backoffice (table <c>licenses.settings_lock_password_hash</c>) → permet
/// un mot de passe différent par client. Inclus dans la signature Ed25519.
/// </summary>
[JsonPropertyName("settingsLockPasswordHash")]
public string? SettingsLockPasswordHash { get; set; }
[JsonPropertyName("serverTime")] [JsonPropertyName("serverTime")]
public DateTime? ServerTime { get; set; } public DateTime? ServerTime { get; set; }

View File

@@ -3,10 +3,21 @@ namespace PSLauncher.Models;
public sealed class LocalConfig public sealed class LocalConfig
{ {
public int SchemaVersion { get; set; } = 1; public int SchemaVersion { get; set; } = 1;
public string ServerBaseUrl { get; set; } = "https://example.com/PS_Launcher/api"; public string ServerBaseUrl { get; set; } = "https://asterionvr.com/PS_Launcher/api";
public string InstallRoot { get; set; } = string.Empty; public string InstallRoot { get; set; } = string.Empty;
public string? LastLaunchedVersion { get; set; } public string? LastLaunchedVersion { get; set; }
/// <summary>
/// Arguments CLI appliqués à CHAQUE lancement de PROSERVE (mode auto ou manuel).
/// Format identique à <see cref="AutoModeConfig.Args"/> : flag <c>-{Key}</c> si
/// <c>Value</c> null/vide, sinon <c>-{Key}={Value}</c>. Concaténés en premier ;
/// si la version lancée est aussi la version auto, les <see cref="AutoModeConfig.Args"/>
/// s'ajoutent DERRIÈRE (Unreal <c>FParse</c> lit dans l'ordre — la dernière occurrence
/// gagne sur un même key). Éditable dans Settings → « Arguments de lancement ».
/// Exemples typiques : <c>-nosplash</c>, <c>-log</c>, <c>-fps=90</c>.
/// </summary>
public List<AutoModeArg> DefaultLaunchArgs { get; set; } = new();
/// <summary> /// <summary>
/// Code de langue de l'UI : "auto" (suit la langue Windows), "fr", "en", /// Code de langue de l'UI : "auto" (suit la langue Windows), "fr", "en",
/// "zh", "th" ou "ar". Tout changement nécessite un redémarrage du launcher. /// "zh", "th" ou "ar". Tout changement nécessite un redémarrage du launcher.
@@ -16,13 +27,13 @@ public sealed class LocalConfig
/// <summary> /// <summary>
/// Nombre de connexions HTTP parallèles utilisées pour télécharger un build. /// Nombre de connexions HTTP parallèles utilisées pour télécharger un build.
/// 1 = comportement legacy single-connection. 4-8 contourne le throttling /// 1 = comportement legacy single-connection. 4-8 contourne le throttling
/// per-connection d'Apache/OVH mutualisé. 16 = défaut depuis v0.19 pour /// per-connection d'Apache/OVH mutualisé. 6 = défaut conservateur pour
/// raccourcir la « queue » en fin de DL : avec 8 segments sur 14 Go, le /// permettre plusieurs PCs en simultané sans saturer le pool PHP-FPM
/// dernier segment doit finir seul ~1.75 Go (visible comme un effondrement /// (gate.php tient un worker par segment pendant tout le DL — 16 segments
/// du débit en fin de course) ; avec 16, c'est ~875 Mo. Push à 24/32 si /// × 2 PCs = 32 workers, ce qui dépasse la limite OVH mutualisé ~30).
/// ton serveur l'accepte (OVH mutualisé tient bien jusqu'à 16-24). /// Push à 12/16 sur un serveur dédié ou si un seul poste télécharge.
/// </summary> /// </summary>
public int ParallelDownloadSegments { get; set; } = 16; public int ParallelDownloadSegments { get; set; } = 6;
/// <summary> /// <summary>
/// Si false, ignore la vérification SHA-256 même si le manifest l'exige. /// Si false, ignore la vérification SHA-256 même si le manifest l'exige.
@@ -64,6 +75,38 @@ public sealed class LocalConfig
/// </summary> /// </summary>
public DocToolConfig DocTool { get; set; } = new(); public DocToolConfig DocTool { get; set; } = new();
/// <summary>
/// Configuration du déploiement de l'API PHP de stats PROSERVE. Même mécanisme
/// que ReportTool / DocTool : copie de <c>_api/</c> bundled dans le ZIP PROSERVE
/// vers <c>{HtdocsRoot}\{FolderName}</c> avec backups horodatés et revert.
/// </summary>
public ApiToolConfig ApiTool { get; set; } = new();
/// <summary>
/// Configuration du merge des settings SteamVR. Si le ZIP contient un sous-dossier
/// <c>_steamvr/</c> avec <c>steamvr.vrsettings</c>, le launcher arrête SteamVR
/// et Vive Business Streaming, fusionne les blocs racine du JSON source dans le
/// fichier SteamVR de l'utilisateur (replace si la clé existe, ajoute sinon),
/// puis laisse SteamVR redémarrer naturellement quand l'utilisateur lance PROSERVE.
/// </summary>
public SteamVrConfig SteamVr { get; set; } = new();
/// <summary>
/// Cache LAN P2P : permet à un PC du LAN de partager les ZIPs déjà téléchargés
/// aux autres PCs (mode serveur), et/ou de chercher les ZIPs sur le LAN avant
/// de fallback sur OVH (mode client). Les deux flags sont indépendants.
/// Économise des dizaines de Go de bande passante Internet pour les salles
/// PROSERVE multi-postes.
/// </summary>
public LanCacheConfig LanCache { get; set; } = new();
/// <summary>
/// Mode auto : lance automatiquement une version sélectionnée au démarrage du
/// launcher, et relance si PROSERVE quitte (avec un grace period 3-5s pendant
/// lequel l'opérateur peut annuler pour reprendre la main).
/// </summary>
public AutoModeConfig AutoMode { get; set; } = new();
/// <summary> /// <summary>
/// Surveillance d'état système affichée en bandeau sous la top bar /// Surveillance d'état système affichée en bandeau sous la top bar
/// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…). /// (pings d'IP, processus requis comme SteamVR / Vive Business Streaming…).
@@ -150,6 +193,68 @@ public sealed class DocToolConfig
public int MaxBackups { get; set; } = 3; public int MaxBackups { get; set; } = 3;
} }
/// <summary>
/// Mêmes paramètres que <see cref="ReportToolConfig"/> mais pour l'API PHP de
/// stats (sous-dossier <c>_api/</c> du ZIP, déployé vers
/// <c>{HtdocsRoot}\proserve</c> par défaut — c'est l'emplacement standard où
/// PROSERVE s'attend à trouver son API côté client, en minuscule).
/// </summary>
public sealed class ApiToolConfig
{
public string HtdocsRoot { get; set; } = @"C:\xampp\htdocs";
public string FolderName { get; set; } = "proserve";
public bool AutoDeploy { get; set; } = true;
public int MaxBackups { get; set; } = 3;
}
/// <summary>
/// Configuration du cache LAN P2P. Symétrique : <see cref="ServerEnabled"/> et
/// <see cref="ClientEnabled"/> sont indépendants — un PC peut être les deux,
/// l'un ou l'autre, ou aucun. Les ports default sont choisis hors range bien
/// connu (47623 HTTP, 47624 UDP discovery) pour éviter les conflits.
/// </summary>
public sealed class LanCacheConfig
{
/// <summary>Active le serveur HTTP local qui expose les ZIPs cachés aux peers du LAN.</summary>
public bool ServerEnabled { get; set; } = false;
/// <summary>Active le mode client : avant de DL depuis OVH, probe les peers LAN.</summary>
public bool ClientEnabled { get; set; } = true;
/// <summary>Port TCP du serveur HTTP local. Doit être autorisé par le firewall (rules posées par l'installeur).</summary>
public int ServerPort { get; set; } = 47623;
/// <summary>Port UDP utilisé pour le broadcast d'auto-découverte des peers.</summary>
public int DiscoveryPort { get; set; } = 47624;
/// <summary>
/// URLs peers ajoutées manuellement (override / fallback de l'auto-discovery).
/// Format : <c>http://10.0.0.5:47623</c> (sans trailing slash). Probées EN PLUS
/// des peers découverts auto, après ceux-ci.
///
/// Default <c>http://10.0.4.100:47623</c> = IP fixe du PC serveur ASTERION
/// dans toutes les salles VR PROSERVE. Permet la découverte instantanée au
/// tout premier démarrage du launcher (avant que le 1er beacon UDP n'arrive).
/// </summary>
public List<string> ManualPeerUrls { get; set; } = new() { "http://10.0.4.100:47623" };
/// <summary>
/// Nombre de ZIPs à conserver en cache local (les N plus récents par mtime).
/// Default 2 = la version courante + la précédente. Permet rollback instantané
/// et le PC serveur peut servir 2 versions aux clients qui n'ont pas encore migré.
/// Les ZIPs des versions actuellement installées ne sont JAMAIS pruned, peu
/// importe leur âge.
/// </summary>
public int MaxCachedVersions { get; set; } = 2;
/// <summary>
/// Timeout par probe HTTP <c>/v1/has/</c> sur un peer. Multiplié par le nombre
/// de peers tentés, c'est le délai max avant fallback OVH. Default 1000 ms ×
/// max 5 peers = 5 s pire cas avant de passer à OVH (acceptable).
/// </summary>
public int ProbeTimeoutMs { get; set; } = 1000;
}
public sealed class HealthChecksConfig public sealed class HealthChecksConfig
{ {
/// <summary>Période de re-vérification, en secondes. 0 = désactivé.</summary> /// <summary>Période de re-vérification, en secondes. 0 = désactivé.</summary>
@@ -176,7 +281,7 @@ public sealed class HealthChecksConfig
Name = "Vive Business Streaming", Name = "Vive Business Streaming",
Icon = "📡", Icon = "📡",
Kind = "Process", Kind = "Process",
Target = "HtcConnectionUtility", Target = "rrserver",
}, },
// PAS de check « casque connecté » par défaut. // PAS de check « casque connecté » par défaut.
// //
@@ -216,9 +321,9 @@ public sealed class HealthCheckEntry
/// Période entre 2 vérifications de CE check, en millisecondes. Indépendant /// Période entre 2 vérifications de CE check, en millisecondes. Indépendant
/// pour chaque entry car un Process check est rapide (~1ms) et peut tourner /// pour chaque entry car un Process check est rapide (~1ms) et peut tourner
/// toutes les secondes, alors qu'un Ping coûte une requête réseau et peut /// toutes les secondes, alors qu'un Ping coûte une requête réseau et peut
/// tourner toutes les 5-10 s. Default 5000 ms. /// tourner toutes les 5-10 s. Default 2000 ms.
/// </summary> /// </summary>
public int RefreshIntervalMs { get; set; } = 5000; public int RefreshIntervalMs { get; set; } = 2000;
/// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms.</summary> /// <summary>Pour Ping : RTT au-dessus duquel le statut passe en Warning (Orange). Default 50 ms.</summary>
public int? PingWarnMs { get; set; } = 50; public int? PingWarnMs { get; set; } = 50;
@@ -230,6 +335,121 @@ public sealed class HealthCheckEntry
public int? PingTimeoutMs { get; set; } = 1000; public int? PingTimeoutMs { get; set; } = 1000;
} }
/// <summary>
/// Configuration du merge SteamVR au moment de l'install d'une nouvelle version
/// PROSERVE. Le ZIP peut contenir un dossier <c>_steamvr/steamvr.vrsettings</c>
/// avec des blocs racine spécifiques au jeu (system.generated.openxr.*, trackers,
/// etc.). Si <see cref="AutoMerge"/> est true, le launcher :
/// <list type="number">
/// <item>tue SteamVR + Vive Business Streaming (qui le relance auto)</item>
/// <item>localise <c>steamvr.vrsettings</c> via le registre Steam (ou <see cref="SettingsFilePathOverride"/>)</item>
/// <item>parse les deux JSON, remplace chaque clé racine du source dans la cible (ou ajoute si absente)</item>
/// <item>écrit le résultat atomiquement (rename .tmp → final)</item>
/// </list>
/// SteamVR redémarrera naturellement quand l'opérateur relance PROSERVE après
/// l'install.
/// </summary>
public sealed class SteamVrConfig
{
/// <summary>
/// Si true, le launcher applique le merge automatiquement à chaque install
/// quand <c>_steamvr/steamvr.vrsettings</c> est présent dans le ZIP. Default
/// true (transparent pour l'opérateur), mais peut être désactivé pour les
/// machines de dev où le settings doit rester intact.
/// </summary>
public bool AutoMerge { get; set; } = true;
/// <summary>
/// Path explicite vers le <c>steamvr.vrsettings</c> à modifier. Null →
/// auto-détection via :
/// <list type="number">
/// <item>HKCU\Software\Valve\Steam → SteamPath → config\steamvr.vrsettings</item>
/// <item>HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath → idem</item>
/// <item>fallback %ProgramFilesX86%\Steam\config\steamvr.vrsettings</item>
/// </list>
/// </summary>
public string? SettingsFilePathOverride { get; set; }
/// <summary>
/// Processus à tuer avant le merge (sans le suffixe .exe). Default = la
/// liste typique SteamVR + Vive Business Streaming. Vive Business Streaming
/// relance auto SteamVR ⇒ il faut le tuer EN PREMIER. Editable depuis
/// Settings pour les setups exotiques (autres runtimes XR).
/// </summary>
public List<string> ProcessesToKill { get; set; } = new()
{
// Vive Business Streaming (à tuer en premier — relance SteamVR sinon)
"VIVE Business Streaming",
"RRConsole",
"RRServer",
"HtcConnectionUtility",
// SteamVR
"vrserver",
"vrmonitor",
"vrcompositor",
"vrdashboard",
"vrwebhelper",
"vrstartup",
};
}
/// <summary>
/// Configuration du mode auto. <see cref="FeatureEnabled"/> est le kill-switch
/// global — quand false, les boutons AUTO ne sont pas affichés sur les rows et
/// le launcher ne fait jamais d'auto-launch. Quand true, l'opérateur peut
/// désigner UNE version via le bouton AUTO ; le launcher lance cette version
/// au démarrage et la relance si elle quitte (après un grace period).
/// </summary>
public sealed class AutoModeConfig
{
/// <summary>
/// Master toggle : si false, le mode auto est désactivé partout (boutons AUTO
/// masqués, pas d'auto-launch). Réglable dans Settings → Avancés.
/// </summary>
public bool FeatureEnabled { get; set; } = false;
/// <summary>
/// Version courante désignée pour l'auto-launch (le bouton AUTO orange sur cette
/// row). Null = aucune désignée (mode auto inactif même si FeatureEnabled=true).
/// </summary>
public string? SelectedVersion { get; set; }
/// <summary>
/// Délai en secondes affiché dans le popup modal de relance après que PROSERVE
/// ait quitté. Pendant ce délai l'opérateur peut cliquer Annuler pour reprendre
/// la main. Clamped 3-10s côté UI.
/// </summary>
public int GracePeriodSeconds { get; set; } = 5;
/// <summary>
/// Arguments CLI passés à PROSERVE_UE_*.exe. Format produit :
/// si <c>Value</c> est null/vide : <c>-{Key}</c> (flag seul)
/// sinon : <c>-{Key}={Value}</c>
/// Exemples : <c>-autoconnect</c>, <c>-login=Jerome2</c>, <c>-ipserver=10.0.4.100</c>
/// </summary>
public List<AutoModeArg> Args { get; set; } = new();
/// <summary>
/// Si true, le launcher attend que TOUS les indicateurs de santé système
/// (<see cref="LocalConfig.HealthChecks"/>) soient en <c>HealthSeverity.Ok</c>
/// avant de démarrer le countdown de lancement automatique. Utile quand
/// PROSERVE dépend d'un serveur API, d'un MySQL local, ou de SteamVR qui
/// peut mettre quelques secondes à se stabiliser au boot. Pendant l'attente,
/// l'opérateur voit un dialog "Vérification de la santé système" avec un
/// bouton Annuler ; le countdown ne démarre qu'une fois tous les indicateurs
/// au vert. Si la liste de health checks est vide, ce flag n'a aucun effet
/// (rien à attendre → countdown immédiat).
/// </summary>
public bool WaitForHealthChecks { get; set; } = false;
}
/// <summary>Une paire clé/valeur d'argument CLI pour PROSERVE. Value optionnel.</summary>
public sealed class AutoModeArg
{
public string Key { get; set; } = string.Empty;
public string? Value { get; set; }
}
public sealed class LicenseConfig public sealed class LicenseConfig
{ {
public string? EncryptedKey { get; set; } public string? EncryptedKey { get; set; }
@@ -244,6 +464,30 @@ public sealed class LicenseConfig
public DateTime? CachedEntitlementUntil { get; set; } public DateTime? CachedEntitlementUntil { get; set; }
public string? CachedOwnerName { get; set; } public string? CachedOwnerName { get; set; }
/// <summary>
/// Channel de distribution attribué à cette license côté serveur (NULL =
/// aucun ⇒ accès uniquement aux versions taggées 'default'). Persisté ici
/// pour que <see cref="ILicenseService.GetCached"/> restaure ce champ entre
/// deux lancements du launcher — sans ça, le channelProvider du
/// ManifestService retombait sur null à chaque relance et le client
/// fetchait toujours le manifest default.
/// </summary>
public string? CachedChannel { get; set; }
/// <summary>
/// Flag <c>canSeeBetas</c> de la license. Persisté pour la même raison
/// que <see cref="CachedChannel"/> : restauration entre deux lancements.
/// </summary>
public bool CachedCanSeeBetas { get; set; }
/// <summary>
/// Hash SHA-256 du password de verrouillage des Settings avancés associé à
/// cette license (NULL = pas de verrouillage). Persisté entre les lancements
/// pour que le verrouillage reste actif même hors-ligne, jusqu'à expiration
/// du cache offline (100 jours).
/// </summary>
public string? CachedSettingsLockPasswordHash { get; set; }
/// <summary> /// <summary>
/// Statut tel que renvoyé par le serveur lors de la dernière validation : "valid", /// Statut tel que renvoyé par le serveur lors de la dernière validation : "valid",
/// "expired", "revoked", "invalid", "machine_limit_exceeded". Persisté pour qu'une /// "expired", "revoked", "invalid", "machine_limit_exceeded". Persisté pour qu'une

View File

@@ -46,6 +46,16 @@ public sealed class LauncherInfo
public sealed class VersionManifest public sealed class VersionManifest
{ {
/// <summary>
/// Identifiant unique de l'entrée manifest, généré par le backoffice
/// (<c>generate_entry_id()</c>). Optionnel côté client : les vieux
/// manifests sans <c>id</c> continuent de fonctionner (rétro-compat).
/// Utilisé principalement pour le row-key côté UI et pour l'install guard
/// anti-collision (deux entries partageant version + folder → refuse).
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("version")] [JsonPropertyName("version")]
public string Version { get; set; } = string.Empty; public string Version { get; set; } = string.Empty;
@@ -70,6 +80,37 @@ public sealed class VersionManifest
[JsonPropertyName("availableForDownload")] [JsonPropertyName("availableForDownload")]
public bool AvailableForDownload { get; set; } = true; public bool AvailableForDownload { get; set; } = true;
/// <summary>
/// Channels qui voient cette version. Liste vide ou champ absent ⇒
/// implicitement <c>["default"]</c> (= visible par tous les clients,
/// peu importe leur channel).
///
/// La sémantique est additive côté serveur : si une license est sur
/// channel "police", elle reçoit (déjà filtrées par le serveur) les
/// versions taggées "default" + celles taggées "police". Le client n'a
/// pas besoin de re-filtrer côté UI — Manifest.php a déjà fait le tri,
/// signé le résultat. On expose quand même la liste ici pour debug et
/// pour un éventuel affichage admin du tag dans l'UI.
/// </summary>
[JsonPropertyName("channels")]
public List<string> Channels { get; set; } = new();
/// <summary>
/// Si true, version taggée BÊTA — affichage d'un badge orange dans le
/// launcher + filtrage : seules les licenses avec <c>canSeeBetas=true</c>
/// voient cette version, les autres l'ignorent (dans <c>RebuildList</c>).
/// Default false → comportement standard (visible par tous).
/// </summary>
[JsonPropertyName("isBeta")]
public bool IsBeta { get; set; }
/// <summary>
/// Note libre destinée aux testeurs, affichée en tooltip quand on hover le
/// badge BÊTA. Optionnel.
/// </summary>
[JsonPropertyName("betaNotes")]
public string? BetaNotes { get; set; }
[JsonPropertyName("heroImageUrl")] [JsonPropertyName("heroImageUrl")]
public string? HeroImageUrl { get; set; } public string? HeroImageUrl { get; set; }

View File

@@ -0,0 +1,355 @@
-- =============================================================================
-- 0001_Init.sql — schéma initial PROSERVE (proserveapi).
--
-- Idempotent : tout est en IF NOT EXISTS / INSERT IGNORE / ADD CONSTRAINT IF
-- NOT EXISTS, donc rejouable sur une DB déjà peuplée sans générer d'erreur.
-- Cible MariaDB 10.0.2+ (XAMPP standard depuis longtemps).
--
-- Note : pas de START TRANSACTION / COMMIT à l'intérieur du fichier — le
-- launcher (PSLauncher.Core.Migrations.DatabaseMigrationService) wrappe déjà
-- chaque .sql dans sa propre transaction pour pouvoir rollback proprement.
--
-- Source d'origine : dump phpMyAdmin 5.2.1 / MariaDB 10.4.32 (5 fév 2026).
-- =============================================================================
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS `proserveapi`
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE `proserveapi`;
-- =============================================================================
-- TABLES
-- =============================================================================
CREATE TABLE IF NOT EXISTS `participates` (
`userId` int(11) NOT NULL COMMENT 'user id',
`sessionId` int(11) NOT NULL COMMENT 'session id',
`score` int(11) NOT NULL DEFAULT 0 COMMENT 'user score in the session',
`firePrecision` float NOT NULL DEFAULT 0 COMMENT 'user precision during the session',
`reactionTime` float NOT NULL DEFAULT 0 COMMENT 'average reaction time of the user in this session',
`nbEnemyHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of enemies hit',
`nbCivilsHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of civils hit',
`damageTaken` int(11) NOT NULL DEFAULT 0 COMMENT 'nb hits received',
`endStatus` int(1) NOT NULL DEFAULT 1 COMMENT 'user status at the end of the session (0=>dead, 1=>alive, add 2 for injured ?)',
`avatar` varchar(255) NOT NULL COMMENT 'avatar of user for this session',
`weapon` varchar(255) NOT NULL COMMENT 'weapon of user for this session',
`role` int(11) NOT NULL DEFAULT 0 COMMENT 'user role in the session',
`results` text NOT NULL DEFAULT '\'\'' COMMENT 'user''s results (objectives) in session (JSON format)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `reacteventmodes` (
`id` int(11) NOT NULL COMMENT 'id',
`displayName` varchar(255) NOT NULL COMMENT 'name',
`description` varchar(255) NOT NULL COMMENT 'description'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `reactevents` (
`id` int(11) NOT NULL COMMENT 'event id',
`srcEventSessionId` int(11) NOT NULL COMMENT 'session id',
`srcEventIndex` int(11) NOT NULL COMMENT 'id of the triggering event that initiated this reaction event',
`reactType` int(11) NOT NULL COMMENT 'react event type',
`reactMode` int(11) NOT NULL DEFAULT 0 COMMENT 'react event mode (penetration, ricochet, termination)',
`hitUserId` int(11) NOT NULL DEFAULT 0 COMMENT 'id of the user that has been hit (0 if enemy/civil/env/game)',
`hitTargetName` varchar(255) NOT NULL COMMENT 'target hit by the event',
`hitBoneName` varchar(255) NOT NULL COMMENT 'bone hit on the target',
`damage` float NOT NULL DEFAULT 0 COMMENT 'damage points for the event',
`targetKilled` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'did target got killed by the event ?',
`objectHitLocationX` float NOT NULL DEFAULT 0 COMMENT 'when hitting a target (object) record X position on texture',
`objectHitLocationY` float NOT NULL DEFAULT 0 COMMENT 'when hitting a target (object) record Y position on texture',
`objectHitTagLocation` varchar(255) NOT NULL COMMENT 'tag on the hit object',
`hitPrecision` float NOT NULL DEFAULT 1 COMMENT 'precision of the hit',
`distance` float NOT NULL DEFAULT 0 COMMENT 'distance from source to target hit',
`reactTime` float NOT NULL DEFAULT 0 COMMENT 'reaction time (for challenges)',
`timeStamp` float NOT NULL DEFAULT 0 COMMENT 'timeStamp react shot was registered'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `reacteventtypes` (
`id` int(11) NOT NULL COMMENT 'type id',
`displayName` varchar(255) NOT NULL COMMENT 'name',
`description` varchar(255) NOT NULL COMMENT 'description'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `sessions` (
`id` int(11) NOT NULL COMMENT 'session id',
`sessionType` int(11) NOT NULL COMMENT 'session type',
`sessionName` varchar(255) NOT NULL COMMENT 'session name',
`sessionDate` datetime NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the session',
`mapName` varchar(255) NOT NULL COMMENT 'map of the session',
`scenarioName` varchar(255) NOT NULL COMMENT 'scenario name that was played',
`success` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'was the session successful (scenario objective reached) ?',
`timeToFinish` float NOT NULL DEFAULT 0 COMMENT 'session duration',
`score` int(11) NOT NULL DEFAULT 0 COMMENT 'user score',
`nbEnemyHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of enemies hit',
`nbCivilsHit` int(11) NOT NULL DEFAULT 0 COMMENT 'nb of civils saved',
`damageTaken` float NOT NULL DEFAULT 0 COMMENT 'total damage taken',
`replayFileName` varchar(255) NOT NULL COMMENT 'filename of the session replay'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `sessiontypes` (
`id` int(11) NOT NULL COMMENT 'session type id',
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the session type',
`description` varchar(255) NOT NULL COMMENT 'description of the session type'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `triggerevents` (
`sessionId` int(11) NOT NULL COMMENT 'id of the user session during which the event was triggered',
`indexCount` int(11) NOT NULL COMMENT 'index of event in session',
`srcUserId` int(11) NOT NULL COMMENT 'id of the user that triggered the shot',
`type` int(11) NOT NULL COMMENT 'type of event',
`successful` tinyint(1) NOT NULL COMMENT 'was the shot (or triggered event) successful (hit a target) ?',
`timeStamp` float NOT NULL COMMENT 'time the event occurred at'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `triggereventtypes` (
`id` int(11) NOT NULL COMMENT 'event type id',
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the event type',
`description` varchar(255) NOT NULL COMMENT 'description of the event type'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `userroles` (
`id` int(11) NOT NULL COMMENT 'session type id',
`displayName` varchar(255) NOT NULL COMMENT 'displayed name of the session type',
`description` varchar(255) NOT NULL COMMENT 'description of the session type'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `users` (
`id` int(11) NOT NULL COMMENT 'user id',
`username` varchar(255) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL COMMENT 'username',
`password` varchar(255) NOT NULL COMMENT 'password',
`firstName` varchar(255) NOT NULL COMMENT 'first name',
`lastName` varchar(255) NOT NULL COMMENT 'last name',
`created` timestamp NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the creation of the user account',
`leftHanded` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'is user left handed?',
`maleGender` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'is user a man (true) or a woman?',
`charSkinAssetName` varchar(255) NOT NULL COMMENT 'name of the asset for the character skin',
`weaponAssetName` varchar(255) NOT NULL COMMENT 'name of the asset for the weapon',
`lastConnection` datetime NOT NULL DEFAULT current_timestamp() COMMENT 'date/time of the last connection',
`avgPrecision` float NOT NULL COMMENT 'average precision when firing',
`avgReaction` float NOT NULL COMMENT 'average reaction time',
`avgFault` float NOT NULL COMMENT 'average error rate',
`avgRapidity` float NOT NULL COMMENT 'average rapidity time',
`size` int(6) NOT NULL DEFAULT 175
) ENGINE=InnoDB DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
CREATE TABLE IF NOT EXISTS `userstatus` (
`id` int(11) NOT NULL COMMENT 'status id',
`displayName` varchar(255) NOT NULL COMMENT 'display name for status (alive, dead)',
`description` varchar(255) NOT NULL COMMENT 'description'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- =============================================================================
-- DONNÉES DE RÉFÉRENCE (INSERT IGNORE = skip silencieux si PK déjà présente)
-- =============================================================================
INSERT IGNORE INTO `reacteventmodes` (`id`, `displayName`, `description`) VALUES
(0, 'Penetration', ''),
(1, 'Ricochet', ''),
(2, 'Termination', '');
INSERT IGNORE INTO `reacteventtypes` (`id`, `displayName`, `description`) VALUES
(0, 'EnemyHit', 'An enemy has been hit'),
(1, 'CivilianHit', 'A civilian has been hit'),
(2, 'PoliceHit', 'Another player has been hit by himself'),
(3, 'ObjectHit', 'A target (object) has been hit'),
(4, 'PaperTargetHit', ''),
(5, 'TargetHit', ''),
(6, 'DeadBodyHit', 'An already-dead body has been hit');
INSERT IGNORE INTO `sessiontypes` (`id`, `displayName`, `description`) VALUES
(0, 'FireRange', ''),
(1, 'Challenge', ''),
(2, 'Protect', ''),
(3, 'De-Escalation', ''),
(4, 'Terrorism', ''),
(5, 'FireExtinction', ''),
(6, 'Rescue', ''),
(7, 'LongRange', '');
INSERT IGNORE INTO `triggereventtypes` (`id`, `displayName`, `description`) VALUES
(0, 'Fire', 'Player has fired');
INSERT IGNORE INTO `userroles` (`id`, `displayName`, `description`) VALUES
(0, 'Police', 'Player is Police/SWAT'),
(1, 'Enemy', 'Player is an Enemy'),
(2, 'Civil', 'Player is a Civilian'),
(3, 'IA', 'Role for IA and NPC');
INSERT IGNORE INTO `userstatus` (`id`, `displayName`, `description`) VALUES
(0, 'Alive', 'avatar is alive'),
(1, 'Lightly Injured', 'avatar has been slightly injured'),
(2, 'Seriously Injured', 'avatar has been seriously injured'),
(3, 'Dead', 'avatar is dead');
-- Comptes utilisateurs initiaux (system + 2 démos). INSERT IGNORE évite
-- d'écraser ce que le client aurait déjà créé si la table users contient
-- des id=0/1/2 différents.
INSERT IGNORE INTO `users` (`id`, `username`, `password`, `firstName`, `lastName`, `created`, `leftHanded`, `maleGender`, `charSkinAssetName`, `weaponAssetName`, `lastConnection`, `avgPrecision`, `avgReaction`, `avgFault`, `avgRapidity`, `size`) VALUES
(0, 'game', '', '', '', '2023-01-24 10:52:35', 0, 0, '', '', '2023-01-24 11:52:35', 0, 0, 0, 0, 0),
(1, 'Jerome', 'Q29udHJvbDM1', 'Jerome', '', '2026-02-05 18:06:06', 0, 1, 'PS_PAWN_VR_V2_POLICE_ARABIC_C', 'Weapon_G17_C', '2026-02-05 12:31:59', 0, 0, 0, 0, 171),
(2, 'Jerome2', 'Q29udHJvbDM1', 'Jerome2', '', '2026-02-05 18:06:24', 0, 1, 'PS_PAWN_VR_V2_POLICE_BLACK_C', 'Weapon_G17_C', '2026-02-05 12:32:23', 0, 0, 0, 0, 175);
-- =============================================================================
-- INDEX & CONTRAINTES (toutes en IF NOT EXISTS pour idempotence)
-- =============================================================================
-- participates : PK composite (userId, sessionId) + FKs
ALTER TABLE `participates`
ADD PRIMARY KEY IF NOT EXISTS (`userId`,`sessionId`),
ADD KEY IF NOT EXISTS `FK_Participates_SessionId` (`sessionId`),
ADD KEY IF NOT EXISTS `FK_Participates_UserStatus` (`endStatus`),
ADD KEY IF NOT EXISTS `FK_Participates_UserRole` (`role`);
ALTER TABLE `reacteventmodes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `reactevents`
ADD PRIMARY KEY IF NOT EXISTS (`id`),
ADD KEY IF NOT EXISTS `FK_React_TypeId` (`reactType`),
ADD KEY IF NOT EXISTS `FK_React_HitUserId` (`hitUserId`),
ADD KEY IF NOT EXISTS `FK_React_EventIndex` (`srcEventIndex`),
ADD KEY IF NOT EXISTS `FK_React_SessionId` (`srcEventSessionId`),
ADD KEY IF NOT EXISTS `FK_React_Mode` (`reactMode`);
ALTER TABLE `reacteventtypes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `sessions`
ADD PRIMARY KEY IF NOT EXISTS (`id`),
ADD KEY IF NOT EXISTS `FK_sessionType` (`sessionType`) USING BTREE;
ALTER TABLE `sessiontypes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `triggerevents`
ADD PRIMARY KEY IF NOT EXISTS (`indexCount`,`sessionId`),
ADD KEY IF NOT EXISTS `FK_TriggerEvent_SrcUserId` (`srcUserId`),
ADD KEY IF NOT EXISTS `FK_TriggerEvent_Type` (`type`),
ADD KEY IF NOT EXISTS `FK_TriggerEvent_UserId` (`sessionId`);
ALTER TABLE `triggereventtypes`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `userroles`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
ALTER TABLE `users`
ADD PRIMARY KEY IF NOT EXISTS (`id`),
ADD UNIQUE KEY IF NOT EXISTS `username` (`username`);
ALTER TABLE `userstatus`
ADD PRIMARY KEY IF NOT EXISTS (`id`);
-- =============================================================================
-- AUTO_INCREMENT (idempotent : MariaDB ignore la valeur si MAX(id) la dépasse)
-- =============================================================================
ALTER TABLE `reactevents`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'event id';
ALTER TABLE `sessions`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'session id';
ALTER TABLE `users`
MODIFY `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'user id';
-- =============================================================================
-- FOREIGN KEYS
-- Syntaxe : ADD FOREIGN KEY IF NOT EXISTS `name` (...) REFERENCES ...
-- (NOTE : en MariaDB, le `CONSTRAINT name FOREIGN KEY` n'accepte PAS de clause
-- `IF NOT EXISTS` ; en revanche `FOREIGN KEY IF NOT EXISTS name` est supporté
-- depuis 10.0.2 et est strictement équivalent côté résultat.)
-- =============================================================================
ALTER TABLE `participates`
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_SessionId` (`sessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserId` (`userId`) REFERENCES `users` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserRole` (`role`) REFERENCES `userroles` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_Participates_UserStatus` (`endStatus`) REFERENCES `userstatus` (`id`);
ALTER TABLE `reactevents`
ADD FOREIGN KEY IF NOT EXISTS `FK_React_HitUserId` (`hitUserId`) REFERENCES `users` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_React_Mode` (`reactMode`) REFERENCES `reacteventmodes` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_React_SessionId` (`srcEventSessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_React_TypeId` (`reactType`) REFERENCES `reacteventtypes` (`id`);
ALTER TABLE `sessions`
ADD FOREIGN KEY IF NOT EXISTS `FK_SessionType` (`sessionType`) REFERENCES `sessiontypes` (`id`);
ALTER TABLE `triggerevents`
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_SrcUserId` (`srcUserId`) REFERENCES `users` (`id`) ON DELETE CASCADE,
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_Type` (`type`) REFERENCES `triggereventtypes` (`id`),
ADD FOREIGN KEY IF NOT EXISTS `FK_TriggerEvent_UserId` (`sessionId`) REFERENCES `sessions` (`id`) ON DELETE CASCADE;
-- =============================================================================
-- VUE sessiondebriefs (CREATE OR REPLACE = idempotent par définition)
-- =============================================================================
CREATE OR REPLACE
ALGORITHM=UNDEFINED
SQL SECURITY DEFINER
VIEW `sessiondebriefs` AS
SELECT
`s`.`id` AS `SessionId`,
`s`.`sessionType` AS `SessionTypeId`,
`st`.`displayName` AS `SessionType`,
`s`.`sessionName` AS `SessionName`,
`s`.`sessionDate` AS `SessionDate`,
`s`.`mapName` AS `MapName`,
`s`.`scenarioName` AS `ScenarioName`,
`s`.`success` AS `SessionSuccessful`,
`s`.`timeToFinish` AS `SessionDuration`,
coalesce(`t`.`type`, -1) AS `TriggerTypeId`,
coalesce(`tt`.`displayName`, '') AS `TriggerType`,
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)) AS `ShooterId`,
`us`.`username` AS `ShooterName`,
(SELECT ifnull(`ps`.`role`, 3)) AS `ShooterRoleId`,
(SELECT `r`.`displayName` FROM `userroles` `r` WHERE `r`.`id` = `ShooterRoleId`) AS `ShooterRole`,
coalesce(`t`.`indexCount`, -1) AS `ShotIndex`,
coalesce(`r`.`id`, -1) AS `ReactId`,
coalesce(`r`.`reactMode`, 2) AS `ReactModeId`,
coalesce(`rm`.`displayName`, '') AS `ReactMode`,
coalesce(`r`.`reactType`, -1) AS `ReactTypeId`,
coalesce(`rt`.`displayName`, '') AS `ReactType`,
coalesce(`r`.`hitUserId`, -1) AS `TargetUserId`,
coalesce(`uh`.`username`, '') AS `TargetUserName`,
(SELECT ifnull(`ph`.`role`, 3)) AS `TargetRoleId`,
(SELECT `r`.`displayName` FROM `userroles` `r` WHERE `r`.`id` = `TargetRoleId`) AS `TargetRole`,
coalesce(`r`.`hitTargetName`, '') AS `TargetName`,
coalesce(`r`.`hitBoneName`, '') AS `TargetBoneName`,
coalesce(`r`.`targetKilled`, 0) AS `TargetKilled`,
coalesce(`r`.`objectHitLocationX`, 0) AS `HitLocationX`,
coalesce(`r`.`objectHitLocationY`, 0) AS `HitLocationY`,
coalesce(`r`.`objectHitTagLocation`, '') AS `HitLocationTag`,
coalesce(`r`.`hitPrecision`, 0) AS `HitPrecision`,
coalesce(`r`.`distance`, 0) AS `HitTargetDistance`,
coalesce(`r`.`reactTime`, 0) AS `ReactionTime`,
coalesce(`r`.`timeStamp`, 0) AS `TimeStamp`,
count(distinct `r`.`id`) AS `NbHit`,
count(distinct `rk`.`srcEventIndex`, `rk`.`hitTargetName`) AS `NbKilled`
FROM `sessions` `s`
LEFT JOIN `participates` `ps` ON `s`.`id` = `ps`.`sessionId`
LEFT JOIN `participates` `ph` ON `s`.`id` = `ph`.`sessionId`
LEFT JOIN `triggerevents` `t` ON `s`.`id` = `t`.`sessionId`
LEFT JOIN `triggereventtypes` `tt` ON `tt`.`id` = `t`.`type`
LEFT JOIN `sessiontypes` `st` ON `st`.`id` = `s`.`sessionType`
LEFT JOIN `reactevents` `r` ON `t`.`indexCount` = `r`.`srcEventIndex`
AND `t`.`sessionId` = `r`.`srcEventSessionId`
LEFT JOIN `users` `uh` ON `uh`.`id` = `r`.`hitUserId`
LEFT JOIN `users` `us` ON `us`.`id` = `t`.`srcUserId`
OR `us`.`id` = `ps`.`userId`
LEFT JOIN `reacteventtypes` `rt` ON `rt`.`id` = `r`.`reactType`
LEFT JOIN `reacteventmodes` `rm` ON `rm`.`id` = coalesce(`r`.`reactMode`, 2)
LEFT JOIN `reactevents` `rk` ON `r`.`id` = `rk`.`id` AND `rk`.`targetKilled` = 1
GROUP BY
`s`.`id`,
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)),
`t`.`indexCount`,
`r`.`hitTargetName`
ORDER BY
`s`.`id` ASC,
(SELECT ifnull(`t`.`srcUserId`, `ps`.`userId`)) ASC,
`t`.`indexCount` ASC,
`r`.`id` ASC;

View File

@@ -0,0 +1,11 @@
-- v1.4.5 : ajoute le SessionType id=7 "LongRange" et renomme id=6 en "Rescue".
-- Idempotent :
-- - INSERT IGNORE skip si la PK id=7 existe déjà (cas où 0001_Init.sql récent
-- contient déjà LongRange dans son INSERT initial)
-- - UPDATE ne génère pas d'erreur si aucune ligne ne match (no-op silencieux)
USE proserveapi;
INSERT IGNORE INTO `sessiontypes` (`id`, `displayName`, `description`)
VALUES (7, 'LongRange', '');
UPDATE `sessiontypes` SET `displayName` = 'Rescue' WHERE `id` = 6;

View File

@@ -0,0 +1,7 @@
-- v1.5.0 : ajoute la colonne `size` à la table `users` (taille en cm).
-- Idempotent : `IF NOT EXISTS` skip silencieusement si la colonne existe déjà
-- (cas d'une install fresh où 0001_Init.sql l'a déjà créée).
USE proserveapi;
ALTER TABLE `users`
ADD COLUMN IF NOT EXISTS `size` INT NOT NULL DEFAULT 175;

View File

@@ -0,0 +1,55 @@
<?php
// define constants for table names
// "data"
define("USERS_TABLE_NAME", "users");
define("SESSIONS_TABLE_NAME", "sessions");
define("PARTICIPATES_TABLE_NAME", "participates");
define("TRIGGEREVENTS_TABLE_NAME", "triggerevents");
define("REACTEVENTS_TABLE_NAME", "reactevents");
// types
define("TRIGGEREVENTTYPES_TABLE_NAME", "triggereventtypes");
define("REACTEVENTTYPES_TABLE_NAME", "reacteventtypes");
define("REACTEVENTMODES_TABLE_NAME", "reacteventmodes");
define("SESSIONTYPES_TABLE_NAME", "sessiontypes");
define("USERSTATUS_TABLE_NAME", "userstatus");
define("USERROLES_TABLE_NAME", "userroles");
// views
define("SESSIONDEBRIEFS_VIEW_NAME", "sessiondebriefs");
//////////////////////////////////////// query strings for SESSIONDEBRIEFS_VIEW ////////////////////////////////////////
// SELECT clause
define("SESSIONDEBRIEFS_VIEW_QUERY_SELECT",
"SELECT S.id AS SessionId, S.sessionType AS SessionTypeId, ST.displayName AS SessionType, S.sessionName AS SessionName, S.sessionDate AS SessionDate, " .
"S.mapName AS MapName, S.scenarioName AS ScenarioName, S.success AS SessionSuccessful, S.timeToFinish AS SessionDuration, " .
"COALESCE(T.type, -1) AS TriggerTypeId, COALESCE(TT.displayName, '') AS TriggerType, (SELECT IFNULL(T.srcUserId, PS.userId)) AS ShooterId, US.username AS ShooterName, " .
"(SELECT IFNULL(PS.role, 3)) AS ShooterRoleId, (SELECT R.displayName FROM userroles R WHERE R.id = ShooterRoleId) AS ShooterRole, " .
"COALESCE(T.indexCount, -1) AS ShotIndex, COALESCE(R.id, -1) AS ReactId, COALESCE(R.reactMode, 2) AS ReactModeId, COALESCE(RM.displayName, '') AS ReactMode, " .
"COALESCE(R.reactType, -1) AS ReactTypeId, COALESCE(RT.displayName, '') AS ReactType, COALESCE(R.hitUserId, -1) AS TargetUserId, " .
"COALESCE(UH.username, '') AS TargetUserName, (SELECT IFNULL(PH.role, 3)) AS TargetRoleId, (SELECT R.displayName FROM userroles R WHERE R.id = TargetRoleId) AS TargetRole, " .
"COALESCE(R.hitTargetName, '') AS TargetName, COALESCE(R.hitBoneName, '') AS TargetBoneName, COALESCE(R.targetKilled, 0) AS TargetKilled, " .
"COALESCE(R.objectHitLocationX, 0) AS HitLocationX, COALESCE(R.objectHitLocationY, 0) AS HitLocationY, COALESCE(R.objectHitTagLocation, '') AS HitLocationTag, " .
"COALESCE(R.hitPrecision, 0) AS HitPrecision, COALESCE(R.distance, 0) AS HitTargetDistance, COALESCE(R.reactTime, 0) AS ReactionTime, " .
"COALESCE(R.timeStamp, 0) AS TimeStamp, COUNT(DISTINCT R.id) AS NbHit, COUNT(DISTINCT RK.srcEventIndex,RK.hitTargetName) AS NbKilled");
// FROM clause
define ("SESSIONDEBRIEFS_VIEW_QUERY_FROM",
" FROM " . SESSIONS_TABLE_NAME . " S LEFT JOIN ". PARTICIPATES_TABLE_NAME . " PS ON (S.id = PS.sessionId) " .
"LEFT JOIN ". PARTICIPATES_TABLE_NAME . " PH ON (S.id = PH.sessionId) LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " T ON (S.id = T.sessionId) " .
"LEFT JOIN " . TRIGGEREVENTTYPES_TABLE_NAME . " TT ON (TT.id = T.type) LEFT JOIN " . SESSIONTYPES_TABLE_NAME . " ST ON (ST.id = S.sessionType) " .
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " R ON ( T.indexCount = R.srcEventIndex AND T.sessionId = R.srcEventSessionId ) " .
"LEFT JOIN " . USERS_TABLE_NAME . " UH ON (UH.id = R.hitUserId) LEFT JOIN " . USERS_TABLE_NAME . " US ON (US.id = T.srcUserId OR US.id = PS.userId ) " .
"LEFT JOIN " . REACTEVENTTYPES_TABLE_NAME . " RT ON (RT.id = R.reactType) LEFT JOIN " . REACTEVENTMODES_TABLE_NAME . " RM ON (RM.id = COALESCE(R.reactMode, 2)) " .
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " RK ON (R.id = RK.id AND RK.targetKilled = 1)");
// GROUP BY clause
define("SESSIONDEBRIEFS_VIEW_QUERY_GROUPBY", " GROUP BY SessionId,ShooterId,ShotIndex,TargetName");
// ORDER BY clause
define("SESSIONDEBRIEFS_VIEW_QUERY_ORDERBY", " ORDER BY SessionId,ShooterId,ShotIndex,ReactId");
// overall query string for SESSIONDEBRIEFS_VIEW (concatenation of SELECT, FROM, GROUP BY, ORDER BY clauses)
define("SESSIONDEBRIEFS_VIEW_QUERY", SESSIONDEBRIEFS_VIEW_QUERY_SELECT . SESSIONDEBRIEFS_VIEW_QUERY_FROM . SESSIONDEBRIEFS_VIEW_QUERY_GROUPBY . SESSIONDEBRIEFS_VIEW_QUERY_ORDERBY);
?>

View File

@@ -0,0 +1,28 @@
<?php
class Database
{
// specify your own database credentials
private $host = "localhost"; //Server
private $db_name = "ProserveAPI"; //Database Name
private $username = "root"; //UserName of Phpmyadmin
private $password = ""; //Password associated with username
public $conn;
// get the database connection
public function getConnection()
{
$this->conn = null;
try
{
$this->conn = new PDO("mysql:host=" . $this->host . ";dbname=" . $this->db_name, $this->username, $this->password);
$this->conn->exec("set names utf8");
}
catch(PDOException $exception)
{
echo "Connection error: " . $exception->getMessage();
}
return $this->conn;
}
}
?>

View File

@@ -0,0 +1,25 @@
<?php
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// set custom error handler
set_error_handler("json_error_handler");
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function missing_parameter_error ($param_name)
{
$error_message = "Missing_Parameter:" . $param_name;
return $error_message;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function json_error_handler ($err_level, $err_message)
{
$err_arr=array (
"status" => false,
"level" => $err_level,
"message" => $err_message
);
//if ($err_level != 2)
print_r(json_encode($err_arr));
}
?>

View File

@@ -0,0 +1,20 @@
<?php
// CORS headers for web interface
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
header("Content-Type: application/json; charset=UTF-8");
// Handle preflight OPTIONS request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// include database and object files
include_once '../config/database.php';
// get database connection
$database = new Database();
$db = $database->getConnection();
?>

View File

@@ -0,0 +1,25 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_participates.php';
// prepare user object
$participation = new UserInGameSession($db);
$participation->sessionId = 1458;
$participation->userId = 6;
$participation->endStatus = 0;
// user leaves session
if ($participation->userLeaves())
{
// update score and averages
if ($participation->updateUserScores())
{
$participation_arr = $participation->getResultArray(true, "User_Left_OK");
print_r(json_encode($participation_arr)); // OK
}
else trigger_error("Calculate_Score_Failed");
}
else trigger_error("Leave_Session_Failed");
?>

View File

@@ -0,0 +1,47 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_reactevent.php';
// prepare user object
$event = new ReactEvent($db);
// read mandatory $_POST properties
// ensure sessionId, trigger index and event type are passed in $_POST parameters
if (isset($_POST['srcEventIndex']) && $_POST['srcEventIndex'] >= 0)
$event->srcEventIndex = $_POST['srcEventIndex'];
else trigger_error( missing_parameter_error("srcEventIndex") );
if (isset($_POST['srcEventSessionId']) && $_POST['srcEventSessionId'] > 0)
$event->srcEventSessionId = $_POST['srcEventSessionId'];
else trigger_error( missing_parameter_error("srcEventSessionId") );
if (isset($_POST['type']))
$event->reactType = $_POST['type'];
else trigger_error( missing_parameter_error("type") );
// read other $_POST properties
$event->hitUserId = $_POST['hitUserId'];
$event->hitTargetName = $_POST['targetName'];
$event->hitBoneName = $_POST['boneName'];
$event->damage = $_POST['damage'];
$event->targetKilled = (strcasecmp( ($_POST['targetKilled'] ?? false), "true" ) == 0) ? 1 : 0;
$event->objectHitLocationX = $_POST['objectHitLocationX'];
$event->objectHitLocationY = $_POST['objectHitLocationY'];
$event->objectHitTagLocation = $_POST['objectHitTagLocation'];
$event->hitPrecision = $_POST['hitPrecision'];
$event->distance = $_POST['distance'];
$event->reactTime = $_POST['reactTime'];
$event->reactMode = $_POST['reactMode'];
$event->timeStamp = $_POST['timestamp'];
// create reactevent
if ($event->record())
{
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
print_r(json_encode($evt_arr)); // OK
}
else trigger_error("Error_Occured");
?>

View File

@@ -0,0 +1,36 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_triggerevent.php';
// prepare user object
$event = new TriggerEvent($db);
// read mandatory $_POST properties
// ensure sessionId, userId and trigger index are passed in $_POST parameters
if (isset($_POST['session']) && $_POST['session'] > 0)
$event->sessionId = $_POST['session'];
else trigger_error( missing_parameter_error("session") );
if (isset($_POST['user']) && $_POST['user'] >= 0)
$event->srcUserId = $_POST['user'];
else trigger_error( missing_parameter_error("user") );
if (isset($_POST['index']) && $_POST['index'] >= 0)
$event->indexCount = $_POST['index'];
else trigger_error( missing_parameter_error("index") );
// read other $_POST properties
$event->type = $_POST['type'];
$event->timeStamp = $_POST['timestamp'];
$event->successful = $_POST['success'];
// create trigger event
if ($event->record())
{
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
print_r(json_encode($evt_arr)); // OK
}
else trigger_error("Error_Occured");
?>

View File

@@ -0,0 +1,14 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$typeId = isset($_POST['typeId']) ? $_POST['typeId'] : -1;
$stats = new StatsObject($db);
$stats->getSessionsForUser(-1, $typeId);
$stats_arr = $stats->getResultArray(true, "All_Sessions_List_OK");
print_r(json_encode($stats_arr));
?>

View File

@@ -0,0 +1,12 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$stats = new StatsObject($db);
$stats->getUsersInSession(-1);
$stats_arr = $stats->getResultArray(true, "All_Users_List_OK");
print_r(json_encode($stats_arr));
?>

View File

@@ -0,0 +1,15 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$userId = isset($_POST['userId']) ? $_POST['userId'] : -1;
$typeId = isset($_POST['typeId']) ? $_POST['typeId'] : -1;
$stats = new StatsObject($db);
$stats->getSessionsForUser($userId, $typeId);
$stats_arr = $stats->getResultArray(true, "User_Sessions_List_OK");
print_r(json_encode($stats_arr)); // OK
?>

View File

@@ -0,0 +1,14 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$sessionId = isset($_POST['sessionId']) ? $_POST['sessionId'] : -1;
$stats = new StatsObject($db);
$stats->getUsersInSession($sessionId);
$stats_arr = $stats->getResultArray(true, "Session_Users_List_OK");
print_r(json_encode($stats_arr)); // OK
?>

View File

@@ -0,0 +1,56 @@
<?php
include_once '../objects/db_table_object.php';
class DBObjectType extends DBTableObject
{
// database connection and table name
//private $conn;
//private $table_name = "sessioneventtypes";
protected $array_key = "type";
// object properties
public $id = -1;
public $displayName = "";
public $description = "";
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"id" => $this->id,
"displayName" => $this->displayName,
"description" => $this->description
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function save ()
{
// query to insert record of new user signup
$query = "INSERT INTO " . $this->table_name . " SET displayName=:displayName, description=:description";
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->displayName=htmlspecialchars(strip_tags($this->displayName));
$this->description=htmlspecialchars(strip_tags($this->description));
// bind values
$stmt->bindParam(":displayName", $this->displayName);
$stmt->bindParam(":description", $this->description);
// execute query
if($stmt->execute())
{
$this->id = $this->conn->lastInsertId();
return true;
}
return false;
}
}
?>

View File

@@ -0,0 +1,591 @@
<?php
include_once "../objects/db_table_object.php";
include_once "../objects/db_participates_results.php";
include_once '../score/score_algo1.php'; // for score calculation
class UserInGameSession extends DBTableObject
{
// database connection and table name
//private $conn;
protected $table_name = PARTICIPATES_TABLE_NAME;
protected $array_key = "participation";
// object properties
public int $userId = -1;
public int $sessionId = 0;
public float $score = 0;
public float $firePrecision = 0.0;
public float $reactionTime = 0.0;
public int $nbEnemyHit = 0;
public int $nbCivilsHit = 0;
public int $damageTaken = 0;
public $endStatus = 0;
public $avatar = "";
public $weapon = "";
public int $role = 0;
public $results = "";
//public $replayFileName = "";
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"userId" => (int)$this->userId,
"sessionId" => (int)$this->sessionId,
"score" => (float)$this->score ?? 0.0,
"firePrecision" => (float)$this->firePrecision ?? 0.0,
"reactionTime" => (float)$this->reactionTime ?? 0.0,
"nbEnemyHit" => (int)$this->nbEnemyHit ?? 0,
"nbCivilsHit" => (int)$this->nbCivilsHit ?? 0,
"damageTaken" => (int)$this->damageTaken ?? 0,
"endStatus" => (int)$this->endStatus ?? 0,
"avatar" => $this->avatar ?? "",
"weapon" => $this->weapon ?? "",
"roleId" => $this->role,
"resultsAsString" => $this->results
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function readRow (array $row)
{
$this->userId = (int)$row['userId'];
$this->sessionId = (int)$row['sessionId'];
$this->score = (int)$row['score'];
$this->firePrecision = (float)$row['firePrecision'];
$this->reactionTime = (float)$row['reactionTime'];
$this->nbEnemyHit = (int)$row['nbEnemyHit'];
$this->nbCivilsHit = (int)$row['nbCivilsHit'];
$this->damageTaken = (int)$row['damageTaken'];
$this->endStatus = (int)$row['endStatus'];
$this->avatar = $row['avatar'];
$this->weapon = $row['weapon'];
$this->role = $row['role'];
$this->results = $row['results'];
//$this->replayFileName = $row['replayFileName'];
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function sanitize ()
{
$this->userId=htmlspecialchars(strip_tags($this->userId));
$this->sessionId=htmlspecialchars(strip_tags($this->sessionId));
$this->avatar=htmlspecialchars(strip_tags($this->avatar));
$this->weapon=htmlspecialchars(strip_tags($this->weapon));
$this->role=htmlspecialchars(strip_tags($this->role));
$this->results=htmlspecialchars(strip_tags($this->results));
//$this->nbCivilsHit=htmlspecialchars(strip_tags($this->nbCivilsHit));
//$this->nbEnemyHit=htmlspecialchars(strip_tags($this->nbEnemyHit));
//$this->score=htmlspecialchars(strip_tags($this->score));
//$this->firePrecision=htmlspecialchars(strip_tags($this->firePrecision));
//$this->damageTaken=htmlspecialchars(strip_tags($this->damageTaken));
//$this->replayFileName=htmlspecialchars(strip_tags($this->replayFileName));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function load ()
{
// select all query with user inputed username and password
$query = "SELECT * FROM " . $this->table_name . " WHERE userId='" . $this->userId . "' AND sessionId='" . $this->sessionId . "'";
// prepare query statement
$stmt = $this->conn->prepare($query);
// execute query
$stmt->execute();
if($stmt->rowCount() > 0)
{
// get retrieved row
$row = $stmt->fetch(PDO::FETCH_ASSOC);
// retrieve session values
$this->readRow($row);
return true;
}
return false;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function registerUser ()
{
//$this->sessionId = $session;
//$this->userId = $user;
//$this->avatar = $avatar;
//$this->weapon = $weapon;
if ($this->alreadyRegistered())
return false;
// query to insert record of new user signup
$query = "INSERT INTO " . $this->table_name . " SET sessionId=:sessionId, userId=:userId, avatar=:avatar, weapon=:weapon, role=:role";
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->sanitize();
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
$stmt->bindParam(":avatar", $this->avatar);
$stmt->bindParam(":weapon", $this->weapon);
$stmt->bindParam(":role", $this->role);
//$stmt->bindParam(":nbCivilsHit", $this->nbCivilsHit);
//$stmt->bindParam(":nbEnemyHit", $this->nbEnemyHit);
//$stmt->bindParam(":score", $this->score);
//$stmt->bindParam(":firePrecision", $this->firePrecision);
//$stmt->bindParam(":damageTaken", $this->damageTaken);
//$stmt->bindParam(":replayFileName", $this->replayFileName);
// execute query
return $stmt->execute();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function alreadyRegistered ()
{
$query = "SELECT * FROM " . $this->table_name . " WHERE userId=:userId AND sessionId=:sessionId";
// prepare query statement
$stmt = $this->conn->prepare($query);
// sanitize
$this->userId=htmlspecialchars(strip_tags($this->userId));
$this->sessionId=htmlspecialchars(strip_tags($this->sessionId));
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
// execute query
$stmt->execute();
return $stmt->rowCount() > 0;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function updateUserScores ()
{
//$this->sessionId = $sessionId;
//$this->userId = $userId;
// load current values
$this->load();
$this->score = 0;
$query = "SELECT sessionType FROM Sessions WHERE id='" . $this->sessionId . "'";
// prepare query
$stmt = $this->conn->prepare($query);
$stmt->execute();
if($stmt->rowCount() > 0)
{
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row['sessionType'] == 0) // FireRange
{
$query = "SELECT R.id, R.hitPrecision, R.objectHitLocationX, R.objectHitLocationY, R.distance, R.objectHitTagLocation " . "
FROM " . REACTEVENTS_TABLE_NAME . " R, " . TRIGGEREVENTS_TABLE_NAME . " T " . "
WHERE R.srcEventSessionId=T.sessionId AND R.srcEventIndex=T.indexCount AND T.srcUserId=:userId AND T.sessionId=:sessionId";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
//$stmt->bindParam(":score", $this->score);
//$stmt->bindParam(":role", $role);
// execute query
$stmt->execute();
$totalPrecision=0;
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
$totalPrecision += $row['hitPrecision'];
$this->score = calculate_firerange_v1($totalPrecision);
}
else
{
$query = "SELECT R.id, R.hitPrecision, R.distance, R.reactType, P.nbCivilsHit " . "
FROM " . REACTEVENTS_TABLE_NAME . " R, " . TRIGGEREVENTS_TABLE_NAME . " T, " . $this->table_name . " P " . "
WHERE R.srcEventIndex=T.indexCount AND R.srcEventSessionId=T.sessionId AND P.sessionId=R.srcEventSessionId AND P.userId=T.srcUserId " . "
AND R.srcEventSessionId=:sessionId AND T.srcUserId=:userId AND (R.reactType=0 OR R.reactType=4 OR R.reactType=5)";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
//$stmt->bindParam(":score", $this->score);
//$stmt->bindParam(":role", $role);
// execute query
$stmt->execute();
if ($stmt->rowCount() > 0)
{
$totalPrecision=0;
$nbCivilHits=0;
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
$nbCivilHits = $row['nbCivilsHit'];
if ($row['reactType']==0) // todo : to keep ?
$totalPrecision += $row['hitPrecision'];
}
$this->score = calculate_v1($totalPrecision, $nbCivilHits);
}
}
}
$query = "UPDATE " . $this->table_name . " SET score=:score WHERE sessionId=:sessionId AND userId=:userId";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
$stmt->bindParam(":score", $this->score);
// update overall averages for participation user
$query_Precision = "SELECT X.UserId AS UserId, SUM(X.NbShotsFired) AS nbFire, SUM(Y.HitPrecision) AS precisionTotal FROM " . "
(SELECT P.userID AS UserId, P.sessionId AS SessionId, TE.indexCount AS IndexCount, COUNT(DISTINCT TE.sessionId, TE.indexCount) AS NbShotsFired
FROM " . PARTICIPATES_TABLE_NAME . " P, " . TRIGGEREVENTS_TABLE_NAME . " TE " . "
WHERE P.sessionId=TE.sessionId AND TE.srcUserId=P.UserId GROUP BY P.UserId, P.sessionId, TE.indexCount) AS X
LEFT JOIN
(SELECT P.userID AS UserId, P.sessionId AS SessionId, TE.indexCount AS IndexCount, MAX(RE.hitPrecision) AS HitPrecision
FROM " . PARTICIPATES_TABLE_NAME . " P, " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE
WHERE P.sessionId=TE.sessionId AND P.sessionId=RE.srcEventSessionId AND TE.srcUserId=P.UserId AND RE.srcEventIndex=TE.indexCount GROUP BY P.UserId, P.sessionId, TE.indexCount) AS Y
ON (X.UserId=Y.UserId AND X.SessionId=Y.SessionId AND X.IndexCount=Y.IndexCount) WHERE X.UserId=:userId
GROUP BY UserId";
$query_ReactionTime = "SELECT P.userID AS UserId, SUM(CASE WHEN RE.reactTime>0 THEN 1 ELSE 0 END) AS nbHits, SUM(RE.reactTime) AS reactTimeTotal
FROM " . PARTICIPATES_TABLE_NAME . " P, " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE
WHERE P.sessionId=TE.sessionId AND P.sessionId=RE.srcEventSessionId AND TE.srcUserId=P.UserId AND RE.srcEventIndex=TE.indexCount AND P.UserId=:userId GROUP BY P.UserId";
$stmt_Precision = $this->conn->prepare($query_Precision);
$stmt_ReactionTime = $this->conn->prepare($query_ReactionTime);
$stmt_Precision->bindParam(":userId", $this->userId);
$stmt_ReactionTime->bindParam(":userId", $this->userId);
$stmt_Precision->execute();
$stmt_ReactionTime->execute();
$res_UpdatePrecision = true;
$res_UpdateReactionTime = true;
if ($stmt_Precision->rowCount() > 0 && $this->userId > 0)
{
// get retrieved row
$row = $stmt_Precision->fetch(PDO::FETCH_ASSOC);
$shotFired = $row['nbFire'];
// retrieve average precision per shot
$userPrecision = $row['precisionTotal']/($shotFired == 0 ? 1 : $shotFired);
$updatePrecision = "UPDATE " . USERS_TABLE_NAME . " SET avgPrecision=" . $userPrecision . " WHERE id=" . $this->userId;
$stmt_UpdatePrecision = $this->conn->prepare($updatePrecision);
$res_UpdatePrecision = $stmt_UpdatePrecision->execute();
}
if ($stmt_ReactionTime->rowCount() > 0 && $this->userId > 0)
{
// get retrieved row
$row = $stmt_ReactionTime->fetch(PDO::FETCH_ASSOC);
$shotHits = $row['nbHits'];
// retrieve average reactionTime per shot
$userReactionTime = $row['reactTimeTotal']/($shotHits == 0 ? 1 : $shotHits);
$updateReactionTime = "UPDATE " . USERS_TABLE_NAME . " SET avgReaction=" . $userReactionTime . " WHERE id=" . $this->userId;
$stmt_UpdateReactionTime = $this->conn->prepare($updateReactionTime);
$res_UpdateReactionTime = $stmt_UpdateReactionTime->execute();
}
return $stmt->execute() && $res_UpdatePrecision && $res_UpdateReactionTime;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function updateObjectives ()
{
// load current values
//$this->load();
// sanitize JSON string
//$this->results=htmlspecialchars(strip_tags($this->results));
// update participation with objective results in database
$query = "UPDATE " . $this->table_name . " SET results=:results WHERE sessionId=:sessionId AND userId=:userId";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
$stmt->bindParam(":results", $this->results);
return $stmt->execute();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function userLeaves ()
{
//$this->userId = $userId;
//$this->sessionId = $sessionId;
// load current values
$this->load();
// queries to update user stats
$query_NbShotFired = "SELECT COUNT(DISTINCT TE.indexCount) AS nbFire FROM " . TRIGGEREVENTS_TABLE_NAME . " TE WHERE TE.sessionId=:sessionId AND TE.srcUserId=:userId";
$query_NbEnemyHit = "SELECT COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS nbEnemyHits FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId=:sessionId AND RE.srcEventSessionId=TE.sessionId AND RE.srcEventIndex=TE.indexCount AND TE.srcUserId=:userId AND RE.reactType=0";
$query_NbCivilHit = "SELECT COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS nbCivilHits FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId=:sessionId AND RE.srcEventSessionId=TE.sessionId AND RE.srcEventIndex=TE.indexCount AND TE.srcUserId=:userId AND RE.reactType=1";
$query_PrecisionAndReactionTime = "SELECT SUM(X.precisionByShot) AS precisionTotal, SUM(X.reactTimeByShot) AS reactTimeTotal, COUNT(CASE WHEN X.reactTime > 0 THEN 1 ELSE 0 END) AS shots
FROM (SELECT COALESCE(RE.hitPrecision,0) AS precisionByShot, COALESCE(RE.reactTime,0) AS reactTimeByShot, RE.reactTime AS reactTime FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId=:sessionId AND TE.srcUserId=:userId AND TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND (RE.reactType=0 OR RE.reactType=4 OR RE.reactType=5) GROUP BY TE.sessionId, TE.srcUserId, TE.indexCount) AS X";
// prepare queries
$stmt_NbShotFired = $this->conn->prepare($query_NbShotFired);
$stmt_NbEnemyHit = $this->conn->prepare($query_NbEnemyHit);
$stmt_NbCivilHit = $this->conn->prepare($query_NbCivilHit);
$stmt_PrecisionAndReactionTime = $this->conn->prepare($query_PrecisionAndReactionTime);
// bind values
$stmt_NbShotFired->bindParam(":sessionId", $this->sessionId);
$stmt_NbShotFired->bindParam(":userId", $this->userId);
$stmt_NbEnemyHit->bindParam(":sessionId", $this->sessionId);
$stmt_NbEnemyHit->bindParam(":userId", $this->userId);
$stmt_NbCivilHit->bindParam(":sessionId", $this->sessionId);
$stmt_NbCivilHit->bindParam(":userId", $this->userId);
$stmt_PrecisionAndReactionTime->bindParam(":sessionId", $this->sessionId);
$stmt_PrecisionAndReactionTime->bindParam(":userId", $this->userId);
// execute queries
$stmt_NbShotFired->execute();
$stmt_NbEnemyHit->execute();
$stmt_NbCivilHit->execute();
$stmt_PrecisionAndReactionTime->execute();
$shotFired=1;
if ($stmt_NbShotFired->rowCount() > 0)
{
// get retrieved row
$row = $stmt_NbShotFired->fetch(PDO::FETCH_ASSOC);
// retrieve nbFire value
$shotFired = $row['nbFire'];
}
if ($stmt_NbEnemyHit->rowCount() > 0)
{
// get retrieved row
$row = $stmt_NbEnemyHit->fetch(PDO::FETCH_ASSOC);
// retrieve nbEnemyHits value
$this->nbEnemyHit = $row['nbEnemyHits'];
}
if ($stmt_NbCivilHit->rowCount() > 0)
{
// get retrieved row
$row = $stmt_NbCivilHit->fetch(PDO::FETCH_ASSOC);
// retrieve nbCivilHits value
$this->nbCivilsHit = $row['nbCivilHits'];
}
if ($stmt_PrecisionAndReactionTime->rowCount() > 0)
{
// get retrieved row
$row = $stmt_PrecisionAndReactionTime->fetch(PDO::FETCH_ASSOC);
$shotsWithReactTime = (int)$row['shots'];
if ($shotsWithReactTime <= 0)
$shotsWithReactTime = 1;
// retrieve average precision per shot
$this->firePrecision = $row['precisionTotal']/($shotFired == 0 ? 1 : $shotFired);
// retrieve average reactionTime per shot
$this->reactionTime = $row['reactTimeTotal']/$shotsWithReactTime;
}
return $this->update() && $stmt_NbShotFired->rowCount() > 0 && $stmt_NbEnemyHit->rowCount() > 0 && $stmt_NbCivilHit->rowCount() > 0 && $stmt_PrecisionAndReactionTime->rowCount() > 0 /* && $stmt_Precision->rowCount() > 0 && $stmt_ReactionTime->rowCount() > 0*/;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function update ()
{
$query = "UPDATE " . $this->table_name . "
SET nbEnemyHit=:nbEnemyHit, nbCivilsHit=:nbCivilsHit, firePrecision=:firePrecision, reactionTime=:reactionTime " . "
WHERE sessionId='".$this->sessionId."' AND userId='".$this->userId."'";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":nbEnemyHit", $this->nbEnemyHit);
$stmt->bindParam(":nbCivilsHit", $this->nbCivilsHit);
$stmt->bindParam(":firePrecision", $this->firePrecision);
$stmt->bindParam(":reactionTime", $this->reactionTime);
// execute query
if ($stmt->execute())
{
// once it has been updated, we have to update the averagePrecision field in the USERS_TABLE_NAME table
$query = "UPDATE " . USERS_TABLE_NAME . "
SET avgPrecision=(SELECT AVG(firePrecision) FROM " . PARTICIPATES_TABLE_NAME . " P WHERE P.userId='".$this->userId."'),
avgReaction=(SELECT AVG(reactionTime) FROM " . PARTICIPATES_TABLE_NAME . " P WHERE P.userId='".$this->userId."')
WHERE users.id='".$this->userId."'";
// prepare query
$stmt = $this->conn->prepare($query);
// execute query
return $stmt->execute();
}
return false;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function updateScore ()
{
$query = "UPDATE " . $this->table_name . " SET score=:score WHERE sessionId=:sessionId AND userId=:userId";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
$stmt->bindParam(":score", $this->score);
return $stmt->execute();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function getAllUsersInSession ()
{
$query = "SELECT DISTINCT userId FROM " . $this->table_name . " WHERE sessionId=:sessionId";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
// execute query
$stmt->execute();
$usersIdForSession = array();
// loop through results
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
// retrieve user ids
array_push( $usersIdForSession, $row['userId'] );
}
return $usersIdForSession;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function copy (UserInGameSession $otherParticipation)
{
$this->userId = $otherParticipation->userId;
$this->sessionId = $otherParticipation->sessionId;
$this->score = $otherParticipation->score; // need to call calculateAverages manually
$this->firePrecision = $otherParticipation->firePrecision; // need to call calculateAverages manually
$this->reactionTime = $otherParticipation->reactionTime; // need to call calculateAverages manually
$this->nbEnemyHit = $otherParticipation->nbEnemyHit; // total
$this->nbCivilsHit = $otherParticipation->nbCivilsHit; // total
$this->damageTaken = $otherParticipation->damageTaken; // total
$this->endStatus = $otherParticipation->endStatus; // TODO : mixed ?
$this->avatar = $otherParticipation->avatar;
$this->weapon = $otherParticipation->weapon;
$this->role = $otherParticipation->role; // TODO
if ($this->results != null && $otherParticipation->results != null)
$this->results = $otherParticipation->results;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function add (UserInGameSession $otherParticipation)
{
if ($this->userId != $otherParticipation->userId)
$this->userId = -1;
if ($this->sessionId != $otherParticipation->sessionId)
$this->sessionId = -1;
$this->score += $otherParticipation->score; // need to call calculateAverages manually
$this->firePrecision += $otherParticipation->firePrecision; // need to call calculateAverages manually
$this->reactionTime += $otherParticipation->reactionTime; // need to call calculateAverages manually
$this->nbEnemyHit += $otherParticipation->nbEnemyHit; // total
$this->nbCivilsHit += $otherParticipation->nbCivilsHit; // total
$this->damageTaken += $otherParticipation->damageTaken; // total
$this->endStatus = $this->endStatus; // TODO : mixed ?
if ($this->avatar != $otherParticipation->avatar)
$this->avatar = "various avatars";
if ($this->weapon != $otherParticipation->weapon)
$this->weapon = "various weapons";
$this->role = $this->role; // TODO
$this->addResults($otherParticipation);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function addResults (UserInGameSession $otherParticipation)
{
// add resultsObject debriefs
if ($this->results != null && $otherParticipation->results != null)
{
$resultsObject = UserInGameSessionResults::fromJsonString($this->results);
$otherParticipationResultsObject = UserInGameSessionResults::fromJsonString($otherParticipation->results);
if ($resultsObject != null && $otherParticipationResultsObject)
{
$resultsObject->add($otherParticipationResultsObject);
// update results string
$this->results = json_encode($resultsObject);
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function calculateAverages ($nb)
{
if ($nb > 0)
{
$this->score /= $nb; // average
$this->firePrecision /= $nb; // average
$this->reactionTime /= $nb; // average
$this->nbEnemyHit = $this->nbEnemyHit; // total
$this->nbCivilsHit = $this->nbCivilsHit; // total
$this->damageTaken = $this->damageTaken; // total
// calculate averages for scores of resultsObject
$resultsObject = UserInGameSessionResults::fromJsonString($this->results);
$resultsObject->calculateAverages($nb);
// update results string
$this->results = json_encode($resultsObject);
}
}
}
?>

View File

@@ -0,0 +1,118 @@
<?php
// Single ObjectiveDebrief
class ObjectiveDebrief
{
public $id = "";
public $description = "";
public float $score = 0.0;
public float $weight = 0.0;
public int $completed = 0;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function add (ObjectiveDebrief $otherDebrief)
{
if ($this->id == $otherDebrief->id)
{
// only add stats with same id
$this->score += $otherDebrief->score;
//$this->weight += $otherDebrief->weight; // weight should be the same if ids are the same
if ($this->completed != 1)
$this->completed = $otherDebrief->completed == 1 ? 1 : $this->completed;
}
}
}
// General GameSession debrief (multiple ObjectiveDebrief)
class UserInGameSessionResults
{
public ObjectiveDebrief $civilian;
public ObjectiveDebrief $time;
public ObjectiveDebrief $enemy;
public ObjectiveDebrief $health;
public ObjectiveDebrief $precision;
public ObjectiveDebrief $reactTime;
public ObjectiveDebrief $ammoLimit;
public ObjectiveDebrief $target;
public ObjectiveDebrief $overall;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function __construct ()
{
$this->civilian = new ObjectiveDebrief();
$this->time = new ObjectiveDebrief();
$this->enemy = new ObjectiveDebrief();
$this->health = new ObjectiveDebrief();
$this->precision = new ObjectiveDebrief();
$this->reactTime = new ObjectiveDebrief();
$this->ammoLimit = new ObjectiveDebrief();
$this->target = new ObjectiveDebrief();
$this->overall = new ObjectiveDebrief();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public static function fromJsonString (string $jsonResults)
{
$instance = new self();
if (!empty($jsonResults))
$instance->createFromJsonArray( json_decode($jsonResults, true) );
return $instance;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
private function createFromJsonArray ($missionDebriefData)
{
// Parse all objectiveDebrief JSON entries in data (civilian, time, enemy, etc.)
foreach ($missionDebriefData as $objectiveDebriefKey => $objectiveDebriefValue)
{
// Create an ObjectiveDebrief variable for each objectiveDebrief JSON entry
$debrief = new ObjectiveDebrief();
foreach ($objectiveDebriefValue as $debriefKey => $debriefValue)
{
// Read all JSON properties and assign them to the ObjectiveDebrief variable
$debrief->{$debriefKey} = $debriefValue;
}
// Assign ObjectiveDebrief variable to the corresponding property in UserInGameSessionResults
$this->{$objectiveDebriefKey} = $debrief;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function add (UserInGameSessionResults $otherParticipationResults)
{
$this->civilian->add($otherParticipationResults->civilian);
$this->time->add($otherParticipationResults->time);
$this->enemy->add($otherParticipationResults->enemy);
$this->health->add($otherParticipationResults->health);
$this->precision->add($otherParticipationResults->precision);
$this->reactTime->add($otherParticipationResults->reactTime);
$this->ammoLimit->add($otherParticipationResults->ammoLimit);
$this->target->add($otherParticipationResults->target);
$this->overall->add($otherParticipationResults->overall);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function calculateAverages ($nb)
{
$this->civilian->score /= $nb;
$this->time->score /= $nb;
$this->enemy->score /= $nb;
$this->health->score /= $nb;
$this->precision->score /= $nb;
$this->reactTime->score /= $nb;
$this->ammoLimit->score /= $nb;
$this->target->score /= $nb;
$this->overall->score /= $nb;
}
}
?>

View File

@@ -0,0 +1,149 @@
<?php
include_once '../objects/db_table_object.php';
include_once '../objects/db_triggerevent.php';
class ReactEvent extends DBTableObject
{
// database connection and table name
//private $conn;
protected $table_name = REACTEVENTS_TABLE_NAME;
protected $array_key = "reactevent";
// object properties
public int $id = -1;
public int $srcEventIndex = -1;
public int $srcEventSessionId = -1;
public int $reactType = -1;
public int $reactMode = -1;
public int $hitUserId = -1;
public string $hitTargetName = "";
public string $hitBoneName = "";
public float $damage = 0.0;
public int $targetKilled = 0;
public float$objectHitLocationX = 0.0;
public float $objectHitLocationY = 0.0;
public string $objectHitTagLocation = "";
public float $hitPrecision = 0.0;
public float $distance = 0.0;
public float $reactTime = 0.0;
public float $timeStamp = 0.0;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"id" => (int)$this->id,
"srcEventIndex" => (int)$this->srcEventIndex,
"srcEventSessionId" => (int)$this->srcEventSessionId,
"reactTypeAsInt" => (int)$this->reactType,
"hitUserId" => (int)$this->hitUserId,
"hitTargetName" => $this->hitTargetName ?? "",
"hitBoneName" => $this->hitBoneName ?? "",
"damage" => (float)$this->damage ?? 0.0,
"targetKilled" => ($this->targetKilled ?? 0) == 1 ? true : false,
"objectHitLocationX" => (float)$this->objectHitLocationX ?? 0.0,
"objectHitLocationY" => (float)$this->objectHitLocationY ?? 0.0,
"objectHitTagLocation" => $this->objectHitTagLocation ?? "",
"hitPrecision" => (float)$this->hitPrecision ?? 0.0,
"timestamp" => (float)$this->timeStamp ?? 0.0,
"distance" => (float)$this->distance ?? 0.0,
"reactTime" => (float)$this->reactTime ?? 0.0,
"reactModeAsInt" => (int)$this->reactMode ?? 0
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function sanitize ()
{
$this->hitUserId=htmlspecialchars(strip_tags($this->hitUserId));
$this->srcEventIndex=htmlspecialchars(strip_tags($this->srcEventIndex));
$this->srcEventSessionId=htmlspecialchars(strip_tags($this->srcEventSessionId));
$this->reactType=htmlspecialchars(strip_tags($this->reactType));
$this->hitTargetName=htmlspecialchars(strip_tags($this->hitTargetName));
$this->hitBoneName=htmlspecialchars(strip_tags($this->hitBoneName));
$this->damage=htmlspecialchars(strip_tags($this->damage));
$this->targetKilled=htmlspecialchars(strip_tags($this->targetKilled));
$this->objectHitLocationX=htmlspecialchars(strip_tags($this->objectHitLocationX));
$this->objectHitLocationY=htmlspecialchars(strip_tags($this->objectHitLocationY));
$this->objectHitTagLocation=htmlspecialchars(strip_tags($this->objectHitTagLocation));
$this->hitPrecision=htmlspecialchars(strip_tags($this->hitPrecision));
$this->distance=htmlspecialchars(strip_tags($this->distance));
$this->reactTime=htmlspecialchars(strip_tags($this->reactTime));
$this->reactMode=htmlspecialchars(strip_tags($this->reactMode));
$this->timeStamp=htmlspecialchars(strip_tags($this->timeStamp));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function record ()
{
$canAddReactEvent = true;
// check that given srcEventId exists, otherwise create it
$query = "SELECT T.indexCount, T.sessionId FROM " . TRIGGEREVENTS_TABLE_NAME . " T WHERE T.indexCount=" . $this->srcEventIndex . " AND T.sessionId=" . $this->srcEventSessionId;
// prepare query
$stmt = $this->conn->prepare($query);
// execute query
$stmt->execute();
if($stmt->rowCount() == 0)
{
$canAddReactEvent = false;
$event = new TriggerEvent($this->conn);
$event->indexCount = $this->srcEventIndex;
$event->sessionId = $this->srcEventSessionId;
$event->srcUserId = 0;
$event->type = 0; // Fire
$event->successful = 1;
$event->timeStamp = $this->timeStamp;
if ($event->recordFromReact())
$canAddReactEvent = true;
}
if ($canAddReactEvent)
{
// query to insert record of new user signup
$query = "INSERT INTO " . $this->table_name . "
SET srcEventIndex=:srcEventIndex, srcEventSessionId=:srcEventSessionId, reactType=:reactType, reactMode=:reactMode, " . "
hitUserId=:hitUserId, hitTargetName=:hitTargetName, hitBoneName=:hitBoneName, damage=:damage, targetKilled=:targetKilled, " . "
objectHitLocationX=:objectHitLocationX, objectHitLocationY=:objectHitLocationY, objectHitTagLocation=:objectHitTagLocation, " . "
hitPrecision=:hitPrecision, distance=:distance, timeStamp=:timeStamp, reactTime=:reactTime";
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->sanitize();
// bind values
$stmt->bindParam(":hitUserId", $this->hitUserId);
$stmt->bindParam(":srcEventIndex", $this->srcEventIndex);
$stmt->bindParam(":srcEventSessionId", $this->srcEventSessionId);
$stmt->bindParam(":reactType", $this->reactType);
$stmt->bindParam(":hitTargetName", $this->hitTargetName);
$stmt->bindParam(":hitBoneName", $this->hitBoneName);
$stmt->bindParam(":damage", $this->damage);
$stmt->bindParam(":targetKilled", $this->targetKilled);
$stmt->bindParam(":objectHitTagLocation", $this->objectHitTagLocation);
$stmt->bindParam(":objectHitLocationX", $this->objectHitLocationX);
$stmt->bindParam(":objectHitLocationY", $this->objectHitLocationY);
$stmt->bindParam(":hitPrecision", $this->hitPrecision);
$stmt->bindParam(":distance", $this->distance);
$stmt->bindParam(":reactTime", $this->reactTime);
$stmt->bindParam(":reactMode", $this->reactMode);
$stmt->bindParam(":timeStamp", $this->timeStamp);
// execute query
if ($stmt->execute())
{
$this->id = $this->conn->lastInsertId();
return true;
}
return false;
}
}
}
?>

View File

@@ -0,0 +1,11 @@
<?php
include_once '../objects/db_event_type.php';
class ReactEventType extends DBObjectType
{
// database connection and table name
//private $conn;
protected $table_name = REACTEVENTMODES_TABLE_NAME;
//protected $array_key = "type";
}
?>

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