Compare commits

...
31 Commits
Author SHA1 Message Date
nicolas 5ac3d153ab Version 2.3.0 2026-11-07 11:50:35 +01:00
nicolas 7d26a4d59c docs: pages multi-jeu 2026-11-07 11:40:26 +01:00
nicolas 924735a0dc chore(uisnapshot): captures avec choix du jeu 2026-11-07 11:40:09 +01:00
nicolas d5a992ad62 test: pages multi-jeu 2026-11-07 11:39:50 +01:00
nicolas e2523d1bff feat(ui): carte de choix du jeu, liste « Jeu » et texte indicatif des listes déroulantes
src/GameLauncher/Themes/Admin.xaml : style des tuiles, modèles de choix et de changement de jeu, utilisés par les deux pages, texte indicatif du nettoyage.
src/GameLauncher/Themes/Controls.xaml : texte indicatif des listes déroulantes vides.
2026-11-07 11:39:33 +01:00
nicolas d444940b63 feat(admin): choix du jeu obligatoire pour les pages Notes de version et Publier une version
src/GameLauncher.Modules.Admin/ViewModels/GameTile.cs (nouveau) : tuiles et règle du jeu choisi d’office.
src/GameLauncher.Modules.Admin/Changelog/ChangelogPanelViewModel.cs
src/GameLauncher.Modules.Admin/Publishing/PublishPanelViewModel.cs
src/GameLauncher.Modules.Admin/Files/FilesPanelViewModel.cs
2026-11-07 11:39:04 +01:00
nicolas 8e69ec108e Merge pull request 'Dev/2.2.0' (#117) from dev/2.2.0 into master
Reviewed-on: #117
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-07 11:04:50 +01:00
nicolas 84bcb80108 Version 2.2.0 2026-11-07 11:02:29 +01:00
nicolas e9f650ef9e docs: traçabilité des consultations et exports du journal 2026-11-07 10:53:55 +01:00
nicolas 27b57526ee chore(uisnapshot): route d'export et exemples de lignes 2026-11-07 10:53:44 +01:00
nicolas 3996f580f6 test: consultations et exports du journal d'audit 2026-11-07 10:53:31 +01:00
nicolas ca0eb044e8 feat(admin): l'export CSV est annoncé au serveur avant l'écriture du fichier
src/GameLauncher.ApiClient/AdminSession.cs : nouvelle méthode RecordAuditExportAsync.
src/GameLauncher.Modules.Admin/Audit/AuditPanelViewModel.cs : annonce de l’export avant l’écriture du fichier, libellés des deux nouvelles actions et nouvelle catégorie de filtre.
2026-11-07 10:53:13 +01:00
nicolas 5b0999e47c feat(server): consultations et exports du journal d'audit inscrits au journal
src/GameLauncher.Contracts/Dtos/AuditDtos.cs : description de l’export (nombre d’entrées, période, filtres) et ses limites.
src/GameLauncher.Contracts/ApiRoutes.cs : nouvelle route v1/audit/export.
server/GameLauncher.Server/Modules/Audit/AuditModule.cs : inscription des consultations (avec la règle des 10 minutes pour les pages suivantes) et des exports, avec contrôle des données reçues.
2026-11-07 10:52:46 +01:00
nicolas 9f6210598c Version 2.2.0 2026-11-07 10:14:42 +01:00
nicolas 08e1476726 docs: Fichiers du serveur, période de l'audit, déploiement 2026-11-07 10:13:39 +01:00
nicolas 818716b7a9 test: Fichiers du serveur, nettoyage, période de l'audit 2026-11-07 10:13:13 +01:00
nicolas 4971c926ac chore(uisnapshot): fausses routes Fichiers et nettoyage 2026-11-07 10:12:50 +01:00
nicolas 99502a2003 feat(ui): barre d'actions fixe, molette sûre, textes indicatifs, page Fichiers 2026-11-07 10:12:28 +01:00
nicolas 81108699c3 feat(admin): retour en haut après une action, période de l'audit, page Fichiers 2026-11-07 10:11:56 +01:00
nicolas 7cce8676b4 feat(server): page Fichiers du serveur, nettoyage d'objects, filtre de date de l'audit 2026-11-07 10:10:05 +01:00
nicolas 0f8f64f00f Merge pull request 'Dev/2.1.0' (#115) from dev/2.1.0 into master
Reviewed-on: #115
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-07 08:53:25 +01:00
nicolas 68ada960cd Note de version 2.1.0 2026-11-07 08:51:08 +01:00
nicolas b08cad3e8e Ajout du dossier Claude outputs 2026-11-07 08:50:56 +01:00
nicolas 42fc1e58cc Version 2.1.0 2026-11-07 08:45:39 +01:00
nicolas b2f1415bff docs: raccourci, nom du launcher, blocage TPM
README.md : nom du raccourci, où apparaît le nom du launcher, comment le blocage TPM est lu.
2026-11-07 08:45:15 +01:00
nicolas d499bfb11a feat(raccourci): le raccourci du Bureau suit le nom affiché du jeu
src/GameLauncher/Services/ShortcutService.cs : nom affiché, renommage sans écrasement, repli sur le nom de launcher.json, suppression des raccourcis nommés par le launcher à la désinstallation.
src/GameLauncher/Services/Interfaces.cs : ajout du nom du raccourci, du renommage et du commentaire du raccourci.
src/GameLauncher/ViewModels/GameViewModel.cs : « Réparer » renomme le raccourci et le signale ; le bouton « Mettre à jour » affiche le nouveau nom.
src/GameLauncher.Modules.Admin/Catalog/CatalogEditors.cs : texte d’aide du champ « Nom affiché ».
src/GameLauncher.Core/Models/LauncherConfig.cs, src/GameLauncher.Core/Catalog/CatalogMerger.cs, src/GameLauncher.Contracts/Catalog/CatalogDocument.cs : commentaires du code mis à jour.

tools/UiSnapshot/Fakes/FakeServices.cs. J’y ai ajouté les deux membres demandés par l’interface :

ShortcutName : renvoie un nom fixe (« Exovival Zombie Reborn ») ;
RenameDesktopShortcuts() : renvoie 0 (aucun raccourci renommé).
2026-11-07 08:45:00 +01:00
nicolas bddd46e9bb fix(admin): la puce TPM n'est plus vue comme toujours bloquée
src/GameLauncher.Modules.Admin/Services/IAdminTpm.cs : nouvelle lecture de l’état de la puce (bon indicateur de blocage, contrôles de la réponse).
src/GameLauncher/Platform/TpmKeyStore.cs : utilise cette lecture ; l’ancienne, fautive, est supprimée.
tests/GameLauncher.Tests/Admin/AdminTpmTests.cs : 3 tests (PC normal non bloqué ; blocage détecté par l’indicateur ou par le compteur ; réponses en erreur ou incomplètes).
2026-11-07 08:43:54 +01:00
nicolas b774ca119f fix(admin): listes lisibles et sans fond blanc pendant le rechargement
src/GameLauncher/Themes/Admin.xaml : nouveau style de liste, couleur de texte des titres (jeux, versions, clés). Ce fichier contient aussi le texte d’aide « Nom du launcher » (point 2). Garde-le dans ce commit, ou indexe ces lignes à part.
src/GameLauncher/Views/Admin/AdminWindow.xaml : la navigation utilise le nouveau style.
src/GameLauncher/Views/Admin/LoginView.xaml : la liste des clés utilise le nouveau style.
2026-11-07 08:43:09 +01:00
nicolas fbfb456b5d Merge pull request 'Dev/2.0.0' (#114) from dev/2.0.0 into master
Reviewed-on: #114
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-07 08:01:45 +01:00
nicolas 15b234995b Ajout : tools/UiSnapshot/ (projet UiSnapshot.csproj, Program.cs, SnapshotOptions.cs, SnapshotRunner.cs, Capture.cs, Report.cs, Poke.cs, Fixture.cs, Fakes/FakeApi.cs, Fakes/FakeServices.cs, Fakes/SampleData.cs, Scenes/PlayerScenes.cs, Scenes/AdminScenes.cs, LISEZMOI.md)
Ajout : tools/ui-snapshot.bat
Modif : GameLauncher.sln — projet UiSnapshot dans le dossier tools (configurations Debug/Release, Any CPU/x64), ui-snapshot.bat dans Solution Items
Modif : src/GameLauncher/Views/MainWindow.xaml et Views/Admin/AdminWindow.xaml — l’icône utilise l’adresse complète pack://application:,,,/GameLauncher;component/Assets/launcher.ico, comme Default.xaml. Aucun effet sur le launcher, et l’outil la trouve même depuis un autre exécutable.
Modif : README.md — l’outil est ajouté au §2 (structure) et au §5 (personnalisation).
2026-11-07 07:58:27 +01:00
fred 3eada0b878 Merge pull request 'Admin/phase7' (#113) from admin/phase7 into dev/2.0.0
Reviewed-on: #113
Reviewed-by: Deville <fred@lanisys.fr>
2026-11-01 21:40:51 +01:00
72 changed files with 7247 additions and 1038 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ Publish/
.vs/
*.user
*.suo
Claude outputs/
# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
+12
View File
@@ -30,6 +30,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
README.md = README.md
docs\ADMIN-ARCHITECTURE.md = docs\ADMIN-ARCHITECTURE.md
tools\admin-key.bat = tools\admin-key.bat
tools\ui-snapshot.bat = tools\ui-snapshot.bat
tools\sign-keyring.bat = tools\sign-keyring.bat
tools\publish-server.bat = tools\publish-server.bat
server-example\web.config = server-example\web.config
@@ -59,6 +60,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Server", "serv
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdminKeyTool", "tools\AdminKeyTool\AdminKeyTool.csproj", "{CC6B01D4-258E-4552-BBB4-1D3CA8E2BE55}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UiSnapshot", "tools\UiSnapshot\UiSnapshot.csproj", "{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Tests", "tests\GameLauncher.Tests\GameLauncher.Tests.csproj", "{DBAE3BFF-DAA8-4BB0-BDA3-3F31AC5C542F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules", "src\GameLauncher.Modules\GameLauncher.Modules.csproj", "{9EBFA383-7928-46F6-8958-F4B38287E380}"
@@ -161,6 +164,14 @@ Global
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.Build.0 = Release|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.ActiveCfg = Release|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.Build.0 = Release|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|x64.ActiveCfg = Debug|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|x64.Build.0 = Debug|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.ActiveCfg = Release|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -178,6 +189,7 @@ Global
{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
+45 -6
View File
@@ -41,6 +41,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
```
@@ -190,6 +191,11 @@ reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bur
ou celle du launcher si le jeu n'est pas installé ou introuvable. Elle suit le jeu s'il est déplacé
(localisation, réparation) ; les raccourcis créés par une version précédente prennent l'icône du jeu
au démarrage du launcher. Une icône choisie par le joueur n'est jamais remplacée.
* **Nom** : le nom affiché du jeu (page « Jeux » du menu d'administration), sinon celui de `launcher.json`.
Un raccourci nommé par le launcher est renommé quand ce nom change : au démarrage du launcher, avec
« Mettre à jour » (*Paramètres du jeu* › *Raccourci*) et avec **Réparer**. Un raccourci renommé par le
joueur garde son nom, et aucun fichier existant du Bureau n'est remplacé (le nom de `launcher.json`
est alors conservé).
| Situation | Comportement |
|---|---|
@@ -346,6 +352,7 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
| Icône | Remplacez `Assets/launcher.ico` |
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
@@ -490,7 +497,13 @@ Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitatio
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
servi aux joueurs (page **Signature**, voir plus bas).
servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la page **Fichiers du serveur**.
* **Ergonomie commune aux pages** : les boutons d'action (« Recharger », « Publier », « Enregistrer »,
« Actualiser », « Exporter… ») restent affichés en bas de la fenêtre, quelle que soit la position dans la
page ; après « Recharger » ou « Publier », la page remonte en haut pour montrer le bandeau du résultat. La
molette fait défiler ce qui est sous le curseur : elle ne change jamais une liste déroulante fermée (même si
elle vient d'être utilisée), et une liste ou un champ qui ne peut plus défiler laisse défiler la page.
* **Ouvrir le menu** : `Ctrl+Maj+Alt+A` dans la fenêtre du launcher, ou **5 clics rapides** (en
moins de 3 s) sur le logo en haut à gauche. Accès discret, pas secret : seule la connexion par clé,
@@ -511,7 +524,8 @@ servi aux joueurs (page **Signature**, voir plus bas).
disparaît avec la puce (carte mère, réinitialisation, Windows réinstallé) : gardez aussi une
clé fichier de secours. Sur le disque : `admin.tpmkey` (référence, sans secret) et `admin.pub`.
Écran de connexion : « Supprimer cette clé de la puce » (puis révoquer la clé sur le serveur) ;
après trop de codes PIN faux, un décompte indique quand la puce acceptera de nouveau un code.
après trop de codes PIN faux, un décompte indique quand la puce acceptera de nouveau un code
(blocage lu dans la puce : attribut `inLockout` et compteur d'erreurs `TPM_PT_LOCKOUT_COUNTER`).
* **Page « Jeux »** (permission `games.edit`) : nom affiché, accroche, description, liens (Discord,
YouTube…), site, thème intégré, icône et fond, fréquence des recherches de mise à jour, jeu masqué,
@@ -519,7 +533,11 @@ servi aux joueurs (page **Signature**, voir plus bas).
`launcher.json`. « Publier » contrôle les valeurs puis écrit `catalog.json` sur le serveur (requête
signée) ; si quelqu'un a publié entre-temps, rien n'est écrasé et la page propose de recharger.
Les jeux eux-mêmes (identifiant, manifest, exécutable, dossiers) restent dans `launcher.json` ;
le nom affiché ne change ni le raccourci, ni le registre, ni les dossiers.
le nom affiché renomme le raccourci du Bureau (voir « Raccourcis du Bureau »), mais ni le registre,
ni les dossiers. Le **nom du launcher** apparaît dans les paramètres du launcher (roue crantée),
l'info-bulle de l'icône de la zone de notification et, avec plusieurs jeux, le titre de la fenêtre
(barre des tâches) ; avec un seul jeu, l'en-tête montre le nom du jeu. Le catalogue publié est pris
en compte au démarrage suivant du launcher.
* **Page « Notes de version »** (permission `changelog.edit`) : corrige les notes des versions **déjà
publiées** dans le `manifest.json` du serveur. Liste des versions, en-tête (titre, sous-titre,
introduction), sections › éléments (modification ou commentaire, étiquette, sous-points, note),
@@ -529,6 +547,13 @@ servi aux joueurs (page **Signature**, voir plus bas).
publiée), rien n'est écrasé et la page propose de recharger. Une nouvelle version et ses notes se
publient toujours avec ManifestBuilder (§3, `--remote-manifest`). Installation sur le VPS :
`server/deploy/LISEZMOI.md`, étape 3 ter.
* **Plusieurs jeux** (pages « Notes de version » et « Publier une version ») : la page s'ouvre sur le
**choix du jeu** (une tuile par jeu de `launcher.json`, aucun jeu par défaut) et n'affiche rien d'autre
avant ce choix ; une liste « Jeu » permet ensuite d'en changer (pas pendant un chargement, un envoi, ni
avec des notes non enregistrées sans confirmation). Rien d'un jeu ne reste affiché pour un autre
(dossier choisi, version en ligne, signature) ; un envoi interrompu reste attaché à son jeu. Les
confirmations et résultats de publication nomment le jeu. Avec un seul jeu, il est choisi d'office.
Page « Fichiers du serveur » : le jeu à nettoyer se choisit de la même façon.
* **Page « Publier une version »** (permission `releases.publish` : propriétaires et administrateurs) :
choix du `manifest.json` produit par le .bat (ManifestBuilder), contrôle du dossier (mêmes règles
que le serveur : chemins sûrs, archive et fichiers présents), version en ligne rappelée (avertissement
@@ -558,7 +583,21 @@ servi aux joueurs (page **Signature**, voir plus bas).
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
de 200). Filtres par catégorie, résultat et texte (nom, IP, clé…), export CSV des entrées filtrées.
de 200). Filtres par **période** (aujourd'hui, 7 ou 30 derniers jours, ce mois-ci, mois précédent, ou dates
« du / au » au format jj/mm/aaaa), catégorie, résultat et texte (nom, IP, clé…). La période est demandée au
serveur (paramètre `before`) et les pages sont chargées jusqu'à son début (5000 entrées au plus) ; l'**export
CSV** reprend toutes les entrées filtrées, période comprise. Le journal se surveille lui-même : chaque
**consultation** (ouverture, « Actualiser », période changée) est inscrite par le serveur (« Journal d'audit
consulté » : auteur, appareil, IP, nombre d'entrées lues), et chaque **export** l'est avant l'écriture du
fichier (« Journal d'audit exporté (CSV) » : nombre d'entrées, période, filtres) ; si le serveur ne peut pas
l'inscrire, le fichier n'est pas écrit. Catégorie de filtre « Consultations et exports du journal ».
* **Page « Fichiers du serveur »** (permission `files.manage` : propriétaires et administrateurs) : contenu du
site public (dossiers, tailles, dates, espace libre du disque) et état de chaque fichier : **Utilisé** (archive,
fichier d'une version, exécutable du launcher, image du catalogue), **Protégé** (`.json`), **Inutilisé**
(supprimable) ou **Non vérifiable**. Un fichier inutilisé se supprime après confirmation ; « Nettoyer le
dossier objects » supprime les fichiers d'un jeu que la version en ligne n'utilise plus (analyse d'abord). Le
serveur relit tous les manifests du site avant chaque suppression et refuse tout fichier encore désigné, ainsi
que pendant une publication. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 sexies.
* **Catalogue côté joueur** : `"catalogUrl"` dans `launcher.json` (racine, https ; en Release, seule
la valeur intégrée à l'exe compte). Lu au démarrage sans le retarder (2,5 s au plus, sinon appliqué
au démarrage suivant), mis en cache (fonctionne hors ligne), toute valeur invalide ignorée. Images :
@@ -568,8 +607,8 @@ servi aux joueurs (page **Signature**, voir plus bas).
administrateur possède une **clé privée** (sur son PC, chiffrée) ; le serveur ne connaît que les
clés publiques. Connexion par défi signé, sessions courtes, et **toute modification signée** par
la clé (un jeton volé ne permet que de lire). Journal d'audit de chaque action.
* **Rôles** : propriétaire (tout, dont la gestion des administrateurs), administrateur (contenu et
publication), éditeur (contenu).
* **Rôles** : propriétaire (tout, dont la gestion des administrateurs), administrateur (contenu,
publication, journal d'audit, fichiers du serveur), éditeur (contenu).
* **Ajouter un administrateur** : invitation (code à usage unique, 24 h) → la personne enregistre sa
clé → un propriétaire approuve en saisissant l'**empreinte** que la personne lui communique.
Un nouveau PC d'un administrateur existant est approuvé par lui-même, depuis un PC déjà enregistré.
+50 -2
View File
@@ -273,6 +273,7 @@ modification ; le journal d'audit sait quelle clé a signé chaque modification.
| `games.edit` | ✔ | ✔ | ✔ | Informations des jeux (phase 3) |
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
| `releases.publish` | ✔ | ✔ | | Publication d'une version (phase 5) |
| `files.manage` | ✔ | ✔ | | Fichiers du serveur : consultation du site, suppression des fichiers inutilisés, nettoyage d'« objects » |
Les permissions sont des chaînes « module.action » : un module futur déclare les siennes
(`news.edit`, `community.moderate`…) et les rôles qui les reçoivent.
@@ -383,6 +384,12 @@ publication n'est pas configurée.
## 10. Notes de version (phase 4 ✅)
* **Plusieurs jeux** (ici et dans « Publier une version ») : la page s'ouvre sur le choix du jeu
(`GameTile`, une tuile par jeu de `launcher.json`, aucun jeu par défaut ; un seul jeu : choisi d'office)
et ne charge rien avant ; liste « Jeu » ensuite (désactivée pendant un chargement ou un envoi). Une
réponse du serveur arrivée après un changement de jeu est ignorée ; dossier, version en ligne et
signature sont effacés au changement ; un envoi interrompu est mémorisé par jeu. « Fichiers du
serveur » : jeu à nettoyer sans choix par défaut s'il y en a plusieurs.
* Éditeur graphique dans le menu admin (page « Notes de version », module `ChangelogAdminModule`,
permission `changelog.edit`) : liste des versions › en-tête (titre, sous-titre, introduction) ›
sections › éléments (modification ou commentaire, étiquette, sous-points, note), ordre ↑ ↓,
@@ -499,14 +506,54 @@ que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste s
l'efface de l'écran) ; il n'est jamais enregistré par le launcher.
* **Journal d'audit** (`audit.read`) : pages de 200 entrées (curseur du serveur), 5 000 au plus en
mémoire, 500 affichées au plus (la page n'est pas virtualisée) ; filtres locaux (catégorie, résultat,
texte ; limite des 5 000 entrées signalée) ; export CSV des entrées filtrées, toutes les cellules entre
guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
texte ; limite des 5 000 entrées signalée) ; **période** (« du / au », raccourcis) : `GET v1/audit?before=`
(ISO 8601, les mois postérieurs ne sont pas lus) puis pages suivantes jusqu'au début de la période, filtre
local en plus ; **consultations inscrites** : chaque `GET v1/audit` sans curseur (ouverture, « Actualiser »,
période) écrit `audit.view` APRÈS la lecture (auteur, clé, IP, entrées lues, borne `before`) ; les pages
suivantes (curseur) n'en réécrivent une que si la dernière consultation inscrite de la même clé date de plus
de 10 minutes (un curseur forgé ne permet donc pas de lire sans trace) ; l'outil `AdminKeyTool` est tracé
de la même façon ; **exports inscrits** : `POST v1/audit/export` (signé, `audit.read` ; nombre d'entrées,
période, filtres en clair, bornés) écrit `audit.export` AVANT que le launcher n'écrive le fichier, qui n'est
pas écrit en cas de refus ou d'erreur (le contenu du fichier reste celui déjà lu : un client modifié pourrait
sauter l'annonce, mais pas la lecture, elle-même tracée) ; export CSV des entrées filtrées (période
comprise), toutes les cellules entre guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
viennent de requêtes anonymes ou de saisies : pas de formule exécutée par le tableur).
* Après une modification, la page se recharge séparément : un échec du rechargement n'efface ni le
résultat annoncé ni un code d'invitation (affiché avant le rechargement). Pas de nouvelle invitation
tant qu'un code non transmis est affiché.
Le serveur inscrit désormais le nom et le rôle de l'invitation supprimée (`admins.invite_deleted`).
### 11 ter. Fichiers du serveur
Module serveur `Files` (`FilesModule`, `ServerFilesStore`) et module launcher `FilesAdminModule` (page « Fichiers du
serveur »), permission `files.manage`.
| Route | Signée | Rôle |
|---|:-:|---|
| `GET v1/files?path=` | | Contenu d'un dossier du site (2 000 éléments au plus, tailles des sous-dossiers dans la limite de 200 000 fichiers parcourus), espace disque, jeux nettoyables |
| `POST v1/files/delete` | ✔ | Supprime UN fichier (`path`, `size` vue dans la liste : refus s'il a changé) |
| `GET v1/games/{id}/objects/prune` | | Analyse : objets du dossier `objects` que plus aucun manifest n'utilise (nombre, taille, empreinte du manifest analysé) |
| `POST v1/games/{id}/objects/prune` | ✔ | Nettoyage, refusé si le manifest a changé depuis l'analyse (`revision_conflict`) |
* **Racine** : `LauncherApi:Files:Root`, sinon le parent commun des dossiers des jeux et du catalogue ; jamais la
racine du disque ni un dossier mêlé aux données privées. Chemins relatifs uniquement (ni `..`, ni `\`, ni `:`, ni
élément caché) ; aucun lien symbolique sur le chemin ; un dossier n'est jamais supprimé.
* **Références** : à chaque requête, tous les `manifest.json` et `launcher-update.json` du site (dossiers cachés et
liens exclus, 50 000 dossiers au plus) et le catalogue configuré sont relus ; les adresses sont résolues comme le
fait le launcher (relatives au fichier, `..` compris ; adresse complète du même serveur rattachée au site grâce à
l'emplacement et à l'adresse du catalogue). État d'un fichier : `protected` (`.json`), `in_use`, `unused`,
`unknown` (fichier de description illisible dans le dossier, ou adresse du même serveur impossible à rattacher :
tout le site), `link`.
* **Suppression** : verrou d'écriture du manifest de chaque jeu configuré qui contient le fichier, refus pendant une
publication (dossier `.publish-…` à côté d'un manifest parent), références relues sous le verrou, seul un fichier
`unused` est supprimé. Audit `files.delete` (chemin relatif, taille libérée).
* **Nettoyage** : jeux configurés uniquement, `objectsBaseUrl` = `objects/` ; seuls les fichiers
`objects/<2 hex>/<empreinte>.bin` au nom exact, ni liens, que le manifest du jeu n'utilise pas ET qu'aucun autre
fichier de description du site ne désigne ni ne rend invérifiable. Sous le verrou du jeu, refus pendant une
publication. Audit `files.prune`.
* **Erreurs** : refus métier en 409 (`publication_in_progress`, `revision_conflict`) ; droits du dossier
(`ReadWritePaths` de systemd) ou fichier occupé en 503 sans chemin du serveur (détail dans journalctl).
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
@@ -594,6 +641,7 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | ✅ |
| **6** | Pages admin « Mes appareils », « Administrateurs » (invitations, approbation par empreinte, rôles, révocation) et « Journal d'audit » (filtres, export CSV) | ✅ |
| **7** | Signature de bout en bout (trousseau signé hors ligne par la clé racine, signature des versions et du catalogue à la publication, page « Signature », vérification côté joueur en « warn » puis « require ») | ✅ |
| **7b** | Page « Fichiers du serveur » (suppression des fichiers inutilisés, nettoyage d'« objects »), période du journal d'audit, barres d'actions fixes et retour en haut, molette sans effet sur les listes fermées | ✅ |
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
@@ -29,7 +29,8 @@ public static class ApiModules
new Modules.Catalog.CatalogModule(),
new Modules.Changelog.ChangelogModule(),
new Modules.Publishing.PublishingModule(),
new Modules.Signing.SigningModule()
new Modules.Signing.SigningModule(),
new Modules.Files.FilesModule()
};
}
@@ -45,6 +45,8 @@ public sealed class LauncherApiOptions
public ContentSigningSettings ContentSigning { get; set; } = new();
public FilesSettings Files { get; set; } = new();
/// <summary>
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
@@ -106,6 +108,17 @@ public sealed class LauncherApiOptions
public bool Require { get; set; }
}
/// <summary>Page « Fichiers du serveur » du menu d'administration.</summary>
public sealed class FilesSettings
{
/// <summary>
/// Racine du site public (chemin absolu, ex. « /srv/exovival/www ») : seul dossier consultable depuis le menu.
/// Vide : dossier parent commun des jeux configurés et du catalogue. La suppression n'est possible que là où
/// systemd autorise l'écriture (ReadWritePaths : dossiers des jeux et du catalogue).
/// </summary>
public string Root { get; set; } = "";
}
/// <summary>Un jeu publié sur ce serveur.</summary>
public sealed class GameSettings
{
@@ -168,6 +181,9 @@ public sealed class LauncherApiOptions
/// <summary>Adresse publique du trousseau, calculée par <see cref="Validate"/>.</summary>
public string KeyringPublicUrl { get; private set; } = "";
/// <summary>Racine du site public pour la page « Fichiers du serveur » (null : page désactivée), calculée par <see cref="Validate"/>.</summary>
public string? FilesRootFullPath { get; private set; }
/// <summary>manifest.json de chaque jeu configuré (chemins absolus), calculé par <see cref="Validate"/>.</summary>
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -201,6 +217,7 @@ public sealed class LauncherApiOptions
ValidateCatalog();
ValidateKeyring();
ValidateGames();
ValidateFiles();
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
@@ -308,6 +325,55 @@ public sealed class LauncherApiOptions
GameManifestFiles = files;
}
private void ValidateFiles()
{
FilesRootFullPath = null;
if (!string.IsNullOrWhiteSpace(Files.Root))
{
var root = Files.Root.Trim();
if (!Path.IsPathRooted(root))
throw new InvalidOperationException($"{Section}:Files:Root doit être un chemin absolu (racine du site, ex. « /srv/exovival/www »).");
var full = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar);
if (!Directory.Exists(full))
throw new InvalidOperationException($"{Section}:Files:Root : le dossier « {full} » n'existe pas.");
if (IsFileSystemRoot(full))
throw new InvalidOperationException($"{Section}:Files:Root ne peut pas être la racine du disque.");
EnsureSeparatedFromData(full, "Files:Root");
FilesRootFullPath = full;
return;
}
// Par défaut : dossier parent commun des jeux et du catalogue (ex. /srv/exovival/www). Rien de configuré,
// racine du disque ou mélange avec les données privées : page désactivée (sans empêcher le démarrage).
var folders = GameManifestFiles.Values.Select(Path.GetDirectoryName)
.Concat(new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Where(f => f is not null).Select(Path.GetDirectoryName))
.OfType<string>().Select(d => Path.GetFullPath(d).TrimEnd(Path.DirectorySeparatorChar)).Distinct().ToList();
if (folders.Count == 0) return;
var common = folders.Count == 1 ? folders[0] : CommonParent(folders);
if (common is null || IsFileSystemRoot(common) || !Directory.Exists(common)) return;
var dataDirectory = RealPath(DataDirectoryFullPath);
var realCommon = RealPath(common);
if (IsSameOrInside(realCommon, dataDirectory) || IsSameOrInside(dataDirectory, realCommon)) return;
FilesRootFullPath = common;
}
private static string? CommonParent(IReadOnlyList<string> folders)
{
string? common = folders[0];
foreach (var folder in folders.Skip(1))
while (common is not null && !IsSameOrInside(folder, common))
common = Path.GetDirectoryName(common);
return common;
}
private static bool IsFileSystemRoot(string path)
{
var full = Path.GetFullPath(path);
return string.Equals(Path.GetPathRoot(full)?.TrimEnd(Path.DirectorySeparatorChar), full.TrimEnd(Path.DirectorySeparatorChar),
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) ||
full == "/";
}
/// <summary>
/// Un dossier public (servi par nginx) et le dossier des données privées ne doivent pas être l'un
/// dans l'autre (liens symboliques résolus).
@@ -1,4 +1,7 @@
using System.Globalization;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
@@ -6,18 +9,113 @@ using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Audit;
/// <summary>Lecture du journal d'audit (permission audit.read), entrées les plus récentes d'abord.</summary>
/// <summary>
/// Lecture du journal d'audit (permission audit.read), entrées les plus récentes d'abord. Chaque consultation est
/// elle-même inscrite au journal (« audit.view »), ainsi que chaque export CSV annoncé par le launcher (« audit.export »).
/// </summary>
public sealed class AuditModule : IApiModule
{
public string Name => "Audit";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
services.AddSingleton<AuditLog>();
services.AddSingleton<AuditConsultations>();
}
public void MapEndpoints(IEndpointRouteBuilder api) =>
api.MapGet(ApiRoutes.Audit, (int? limit, string? cursor, AuditLog audit) =>
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Audit, Read).RequirePermission(AdminPermissions.AuditRead);
api.MapPost(ApiRoutes.AuditExport, RecordExport).RequirePermission(AdminPermissions.AuditRead).RequireSignedRequest();
}
private static IResult Read(int? limit, string? cursor, string? before, HttpContext context, AuditLog audit, AuditConsultations consultations)
{
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
DateTimeOffset? until = null;
if (!string.IsNullOrEmpty(before))
{
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
return Results.Json(audit.Read(limit ?? 50, cursor), ApiJson.Options);
}).RequirePermission(AdminPermissions.AuditRead);
// Format ISO 8601 uniquement (« 2026-11-07T00:00:00Z »), borné à des dates plausibles.
if (before.Length > 40 ||
!DateTimeOffset.TryParse(before, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) ||
parsed.Year is < 2000 or > 9998)
return ApiResults.BadRequest("Date « before » invalide (format ISO 8601 attendu).");
until = parsed;
}
var page = audit.Read(limit ?? 50, cursor, until);
// Consultation inscrite APRÈS la lecture (la page renvoyée montre le journal tel qu'il était). Première page
// (ouverture, « Actualiser », période changée) : toujours ; pages suivantes (« Charger plus ») : une fois par
// clé tant que la consultation date de moins de 10 minutes (pas une ligne par page de 200 entrées).
var actor = context.Admin();
var continuation = !string.IsNullOrEmpty(cursor);
if (consultations.ShouldRecord(actor.Key.KeyId, continuation))
{
var details = new List<string> { $"{page.Entries.Count} entrée(s) lue(s)" };
if (continuation) details.Add("entrées plus anciennes");
if (until is { } date) details.Add($"antérieures au {date.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)} UTC");
audit.Write("audit.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
details: string.Join(" ; ", details));
}
return Results.Json(page, ApiJson.Options);
}
private static IResult RecordExport(HttpContext context, AuditLog audit)
{
AuditExportRequest? request;
try
{
request = JsonSerializer.Deserialize<AuditExportRequest>(context.CapturedBody(), ApiJson.Options);
}
catch (JsonException)
{
return ApiResults.BadRequest("Annonce d'export illisible.");
}
if (request is null || request.Count is < 0 or > AuditExportLimits.MaxCount ||
request.Filters is { Length: > AuditExportLimits.MaxFiltersLength } ||
request.From is { Year: < 2000 or > 9998 } || request.To is { Year: < 2000 or > 9998 } ||
(request.From is { } from && request.To is { } to && from > to))
return ApiResults.BadRequest("Annonce d'export invalide.");
var period = (request.From, request.To) switch
{
(null, null) => "toutes les dates",
({ } f, null) => $"depuis le {Day(f)}",
(null, { } t) => $"jusqu'au {Day(t)}",
({ } f, { } t) => $"du {Day(f)} au {Day(t)}"
};
var actor = context.Admin();
audit.Write("audit.export", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
"fichier CSV",
$"{request.Count} entrée(s) ; {period}" + (string.IsNullOrWhiteSpace(request.Filters) ? "" : $" ; filtres : {request.Filters}"));
return Results.NoContent();
}
private static string Day(DateOnly day) => day.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture);
}
/// <summary>Dernière consultation inscrite par clé (évite une ligne par page lors de « Charger plus »).</summary>
public sealed class AuditConsultations
{
/// <summary>Pages suivantes non réinscrites pendant ce délai après la dernière consultation inscrite.</summary>
public static readonly TimeSpan ContinuationWindow = TimeSpan.FromMinutes(10);
private readonly TimeProvider _time;
private readonly Dictionary<string, DateTimeOffset> _lastRecorded = new(StringComparer.Ordinal);
public AuditConsultations(TimeProvider time) => _time = time;
public bool ShouldRecord(string keyId, bool continuation)
{
var now = _time.GetUtcNow();
lock (_lastRecorded)
{
if (continuation && _lastRecorded.TryGetValue(keyId, out var last) && now - last < ContinuationWindow && now >= last)
return false;
if (_lastRecorded.Count > 10_000) _lastRecorded.Clear(); // seules les clés enregistrées arrivent ici
_lastRecorded[keyId] = now;
return true;
}
}
}
@@ -0,0 +1,129 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Files;
/// <summary>
/// Page « Fichiers du serveur » (permission files.manage : propriétaires, administrateurs) : consultation du site
/// public, suppression d'un fichier inutilisé, nettoyage du dossier « objects » d'un jeu. Suppressions signées et
/// inscrites au journal d'audit ; le serveur revérifie les manifests au moment de supprimer (ServerFilesStore).
/// </summary>
public sealed class FilesModule : IApiModule
{
public string Name => "Files";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<ServerFilesStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
const string permission = AdminPermissions.FilesManage;
api.MapGet(ApiRoutes.Files, List).RequirePermission(permission);
api.MapPost(ApiRoutes.FilesDelete, Delete).RequirePermission(permission).RequireSignedRequest();
api.MapGet(ApiRoutes.ObjectsPrunePattern, PreviewPrune).RequirePermission(permission);
api.MapPost(ApiRoutes.ObjectsPrunePattern, Prune).RequirePermission(permission).RequireSignedRequest();
}
private static IResult List(string? path, ServerFilesStore store, ILogger<FilesModule> logger)
{
if (path is { Length: > ServerFilesLimits.MaxPathLength }) return ApiResults.BadRequest("Chemin trop long.");
return Guard(logger, "Consultation des fichiers", () => Results.Json(store.List(path), ApiJson.Options));
}
private static IResult Delete(HttpContext context, ServerFilesStore store, AuditLog audit, ILogger<FilesModule> logger)
{
ServerFileDeleteRequest? request;
try
{
request = JsonSerializer.Deserialize<ServerFileDeleteRequest>(context.CapturedBody(), ApiJson.Options);
}
catch (JsonException)
{
return ApiResults.BadRequest("Requête de suppression illisible.");
}
if (request is null || string.IsNullOrWhiteSpace(request.Path) || request.Path.Length > ServerFilesLimits.MaxPathLength || request.Size < 0)
return ApiResults.BadRequest("Requête de suppression invalide.");
return Guard(logger, "Suppression d'un fichier", () =>
{
var result = store.Delete(request.Path, request.Size);
Audit(audit, context, "files.delete", result.Path, $"{Megabytes(result.FreedBytes)} libéré(s)");
return Results.Json(result, ApiJson.Options);
});
}
private static IResult PreviewPrune(string gameId, ServerFilesStore store, ILogger<FilesModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
return Guard(logger, "Analyse du dossier objects", () => Results.Json(store.PreviewPrune(gameId), ApiJson.Options));
}
private static IResult Prune(string gameId, HttpContext context, ServerFilesStore store, AuditLog audit, ILogger<FilesModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
ObjectsPruneRequest? request;
try
{
request = JsonSerializer.Deserialize<ObjectsPruneRequest>(context.CapturedBody(), ApiJson.Options);
}
catch (JsonException)
{
return ApiResults.BadRequest("Requête de nettoyage illisible.");
}
var manifestSha = (request?.ManifestSha256 ?? "").ToLowerInvariant();
if (!PublishingLimits.IsValidSha256(manifestSha)) return ApiResults.BadRequest("Empreinte du manifest analysé invalide.");
return Guard(logger, "Nettoyage du dossier objects", () =>
{
var result = store.Prune(gameId, manifestSha,
(file, ex) => logger.LogWarning(ex, "Nettoyage du dossier objects : {File} non supprimé", Path.GetFileName(file)));
Audit(audit, context, "files.prune", $"{result.GameId} objects",
$"{result.Deleted} fichier(s) supprimé(s), {Megabytes(result.FreedBytes)} libéré(s)" +
(result.Failed > 0 ? $" ; {result.Failed} échec(s)" : "") + $" ; manifest {manifestSha[..12]}");
return Results.Json(result, ApiJson.Options);
});
}
/// <summary>Erreurs de fichiers → 503 avec un message sans chemin du serveur (le détail va dans journalctl).</summary>
private static IResult Guard(ILogger logger, string what, Func<IResult> action)
{
try
{
return action();
}
catch (InvalidDataException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Le serveur n'a pas pu lire le manifest en ligne : {ex.Message}");
}
catch (UnauthorizedAccessException ex)
{
logger.LogError(ex, "{What}", what);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas le droit de modifier ce dossier : seuls les dossiers des jeux et du catalogue sont autorisés en écriture " +
"(ReadWritePaths, server/deploy/LISEZMOI.md).");
}
catch (IOException ex)
{
logger.LogError(ex, "{What}", what);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu accéder au fichier : droits du dossier (ReadWritePaths) ou fichier en cours d'utilisation (server/deploy/LISEZMOI.md).");
}
}
private static void Audit(AuditLog audit, HttpContext context, string action, string target, string details)
{
var actor = context.Admin();
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(), target, details);
}
private static string Megabytes(long bytes) =>
bytes >= 1024 * 1024 ? $"{bytes / (1024.0 * 1024):0.#} Mo" : bytes >= 1024 ? $"{bytes / 1024.0:0.#} Ko" : $"{bytes} octet(s)";
}
+10 -3
View File
@@ -145,10 +145,11 @@ public sealed class AuditLog
/// <summary>
/// Entrées les plus récentes d'abord. <paramref name="cursor"/> : valeur NextCursor de la page
/// précédente (« aaaa-MM:position »). Les fichiers sont lus depuis la fin, par blocs : la mémoire
/// utilisée ne dépend pas de leur taille.
/// précédente (« aaaa-MM:position »). <paramref name="before"/> : seulement les entrées antérieures
/// à cet instant (filtre « jusqu'au » du menu ; les mois plus récents ne sont pas lus). Les fichiers
/// sont lus depuis la fin, par blocs : la mémoire utilisée ne dépend pas de leur taille.
/// </summary>
public AuditPage Read(int limit, string? cursor)
public AuditPage Read(int limit, string? cursor, DateTimeOffset? before = null)
{
limit = Math.Clamp(limit, 1, MaxPageSize);
var months = Directory.Exists(_directory)
@@ -169,17 +170,23 @@ public sealed class AuditLog
startMonth = parts[0];
}
// Mois postérieurs à « before » : sautés sans être lus (une entrée écrite au tout début d'un mois
// peut porter l'heure de la fin du mois précédent : le mois de « before » est donc lu en entier).
var lastMonth = before?.UtcDateTime.ToString("yyyy-MM", CultureInfo.InvariantCulture);
var entries = new List<AuditEntry>(limit);
for (var m = 0; m < months.Count; m++)
{
var month = months[m];
if (startMonth is not null && string.CompareOrdinal(month, startMonth) > 0) continue;
if (lastMonth is not null && string.CompareOrdinal(month, lastMonth) > 0) continue;
var end = month == startMonth ? startOffset : long.MaxValue;
// Lignes illisibles (écriture interrompue, modification manuelle) ignorées sans rien masquer :
// la lecture continue jusqu'à obtenir « limit » entrées lisibles.
foreach (var (start, line) in ReadBackward(Path.Combine(_directory, $"audit-{month}.jsonl"), end))
{
if (TryParse(line) is not { } entry) continue;
if (before is { } limitDate && entry.Timestamp >= limitDate) continue;
entries.Add(entry);
if (entries.Count == limit)
{
@@ -0,0 +1,765 @@
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Page « Fichiers du serveur » : consultation du site public (racine LauncherApi:Files:Root), suppression d'un
/// fichier inutilisé et nettoyage du dossier « objects » d'un jeu.
///
/// Règles (le serveur ne fait confiance qu'à ce qu'il lit lui-même, au moment de supprimer) :
/// • chemins relatifs à la racine uniquement : ni « .. », ni chemin absolu, ni élément caché (« .publish-… »),
/// ni lien symbolique sur le chemin ; un dossier n'est jamais supprimé ;
/// • fichiers .json (manifest, catalogue, trousseau, mise à jour du launcher) : JAMAIS supprimés ;
/// • un fichier référencé par un fichier de description d'un dossier parent (archive et fichiers d'un
/// manifest.json, exécutable de launcher-update.json, images du catalogue) n'est pas supprimable ;
/// un fichier de description illisible rend tout son dossier non supprimable (impossible de vérifier) ;
/// • vérification refaite sous le verrou d'écriture du manifest du jeu (celui de la publication et des notes),
/// et refus pendant une publication de ce jeu (dossier « .publish-… » présent) ;
/// • la suppression n'est possible que là où systemd autorise l'écriture (ReadWritePaths).
/// </summary>
public sealed partial class ServerFilesStore
{
private const string ManifestName = "manifest.json";
private const string LauncherUpdateName = "launcher-update.json";
private const string StagingPrefix = ".publish-";
/// <summary>Fichier de description lu au plus (même borne que le manifest).</summary>
private const long MaxDescriptionBytes = PublishedFiles.MaxManifestBytes;
private static readonly StringComparison PathComparison =
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
private static readonly StringComparer PathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
[GeneratedRegex(@"\A[0-9a-f]{2}\z")]
private static partial Regex PrefixRegex();
[GeneratedRegex(@"\A[0-9a-f]{64}\.bin\z")]
private static partial Regex ObjectNameRegex();
/// <summary>Les dépôts de publication (« .publish-… ») sont « cachés » sous Linux : énumération explicite.</summary>
private static readonly EnumerationOptions IncludingHidden = new() { AttributesToSkip = 0, IgnoreInaccessible = true };
private readonly string? _root;
private readonly IReadOnlyDictionary<string, string> _manifests;
private readonly string _dataDirectory;
private readonly string? _catalogFile;
private readonly string _catalogUrl;
public ServerFilesStore(IOptions<LauncherApiOptions> options)
: this(options.Value.FilesRootFullPath, options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath,
options.Value.CatalogPublicFileFullPath, options.Value.CatalogPublicUrl) { }
public ServerFilesStore(string? root, IReadOnlyDictionary<string, string> manifests, string dataDirectory,
string? catalogFile, string catalogUrl)
{
_root = root is null ? null : Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar);
_manifests = manifests;
_dataDirectory = dataDirectory;
_catalogFile = catalogFile is null ? null : Path.GetFullPath(catalogFile);
_catalogUrl = catalogUrl;
}
public bool Enabled => _root is not null;
// ================================================================== Consultation
public ServerFolder List(string? relativePath)
{
var root = RequireRoot();
var (directory, relative) = Resolve(relativePath ?? "");
if (!Directory.Exists(directory)) throw ApiRejection.NotFound("Dossier introuvable (supprimé ou renommé entre-temps ?).");
var references = ScanReferences();
var infos = new List<FileSystemInfo>();
var truncated = false;
foreach (var info in new DirectoryInfo(directory).EnumerateFileSystemInfos("*", new EnumerationOptions
{
AttributesToSkip = 0,
IgnoreInaccessible = true,
RecurseSubdirectories = false
}))
{
if (info.Name.StartsWith('.')) continue; // dépôts de publication, fichiers cachés : jamais affichés
if (infos.Count >= ServerFilesLimits.MaxEntries)
{
truncated = true;
break;
}
infos.Add(info);
}
var budget = new ScanBudget(ServerFilesLimits.MaxScannedFiles);
var entries = infos
.OrderBy(i => i is DirectoryInfo ? 0 : 1)
.ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
.Select(info => Describe(info, references, budget))
.ToList();
long free = -1, total = -1;
try
{
var drive = new DriveInfo(root);
free = drive.AvailableFreeSpace;
total = drive.TotalSize;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException)
{
// information facultative
}
return new ServerFolder
{
Path = relative,
RootName = Path.GetFileName(root) is { Length: > 0 } name ? name : root,
Entries = entries,
Truncated = truncated,
FreeBytes = free,
TotalBytes = total,
PrunableGames = _manifests.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList()
};
}
private ServerFileEntry Describe(FileSystemInfo info, References references, ScanBudget budget)
{
var modified = new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero);
if (info.LinkTarget is not null)
return new ServerFileEntry
{
Name = info.Name,
IsDirectory = info is DirectoryInfo,
Size = -1,
FileCount = -1,
Modified = modified,
Status = ServerFileStatus.Link,
Reason = "Lien symbolique : ni suivi ni supprimé depuis le menu."
};
if (info is DirectoryInfo directory)
{
var (size, count) = budget.Measure(directory);
return new ServerFileEntry
{
Name = info.Name,
IsDirectory = true,
Size = size,
FileCount = count,
Modified = modified,
Status = ServerFileStatus.Directory
};
}
var (status, reason) = references.StatusOf(info.FullName);
return new ServerFileEntry
{
Name = info.Name,
Size = ((FileInfo)info).Length,
FileCount = 1,
Modified = modified,
Status = status,
Reason = reason
};
}
/// <summary>Taille des sous-dossiers, dans la limite d'un nombre de fichiers parcourus par requête.</summary>
private sealed class ScanBudget
{
private long _remaining;
public ScanBudget(long files) => _remaining = files;
public (long Size, long Count) Measure(DirectoryInfo directory)
{
if (_remaining <= 0) return (-1, -1);
long size = 0, count = 0;
try
{
// Fichiers cachés (dépôts « .publish-… ») et liens symboliques ignorés.
foreach (var file in directory.EnumerateFiles("*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReparsePoint
}))
{
if (--_remaining < 0) return (-1, -1);
size += file.Length;
count++;
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return (-1, -1);
}
return (size, count);
}
}
// ================================================================== Suppression d'un fichier
public ServerFileDeleteResult Delete(string relativePath, long expectedSize)
{
RequireRoot();
var (path, relative) = Resolve(relativePath);
if (relative.Length == 0 || Directory.Exists(path))
throw ApiRejection.BadRequest("Seul un fichier peut être supprimé (jamais un dossier).");
var file = new FileInfo(path);
if (!file.Exists) throw ApiRejection.NotFound("Fichier introuvable (déjà supprimé ?).");
// Même verrou que la publication et les notes de version pour chaque jeu dont le dossier contient ce fichier :
// le manifest ne peut pas changer entre la vérification et la suppression.
var locks = new List<FileStream>();
try
{
foreach (var gameId in GamesContaining(path)) locks.Add(PublishedFiles.LockGame(_dataDirectory, gameId));
file.Refresh();
if (!file.Exists) throw ApiRejection.NotFound("Fichier introuvable (déjà supprimé ?).");
if (file.LinkTarget is not null) throw ApiRejection.Conflict("Lien symbolique : jamais supprimé depuis le menu.");
if (file.Length != expectedSize)
throw ApiRejection.Conflict("Le fichier a changé depuis l'affichage de la liste : actualisez avant de le supprimer.",
ApiErrorCodes.RevisionConflict);
if (PublicationFolderInProgress(path) is { } game)
throw ApiRejection.Conflict(
$"Une publication est en cours dans « {RelativeDisplay(game)} » : réessayez une fois la version en ligne ou la publication abandonnée.",
ApiErrorCodes.PublicationInProgress);
// Vérification refaite maintenant (manifests relus) : seul un fichier inutilisé est supprimé.
var (status, reason) = ScanReferences().StatusOf(path);
if (status != ServerFileStatus.Unused)
throw ApiRejection.Conflict(status == ServerFileStatus.Protected || status == ServerFileStatus.InUse
? $"Suppression refusée : {reason}"
: $"Suppression refusée : {reason ?? "impossible de vérifier que ce fichier est inutilisé."}");
var size = file.Length;
File.Delete(path);
return new ServerFileDeleteResult { Path = relative, FreedBytes = size };
}
finally
{
foreach (var handle in locks) handle.Dispose();
}
}
// ================================================================== Nettoyage du dossier « objects » d'un jeu
public ObjectsPrunePreview PreviewPrune(string gameId)
{
var (manifestPath, key) = Locate(gameId);
return Analyze(manifestPath, key).Preview;
}
public ObjectsPruneResult Prune(string gameId, string manifestSha256, Action<string, Exception> logFailure)
{
var (manifestPath, key) = Locate(gameId);
using (PublishedFiles.LockGame(_dataDirectory, key))
{
var analysis = Analyze(manifestPath, key);
if (!string.Equals(analysis.Preview.ManifestSha256, manifestSha256, StringComparison.OrdinalIgnoreCase))
throw ApiRejection.Conflict("Le manifest en ligne a changé depuis l'analyse : relancez l'analyse avant de nettoyer.",
ApiErrorCodes.RevisionConflict);
if (analysis.Preview.PublicationInProgress)
throw ApiRejection.Conflict(
"Une publication de ce jeu est en cours : nettoyez une fois la version en ligne (ou la publication abandonnée).",
ApiErrorCodes.PublicationInProgress);
int deleted = 0, failed = 0;
long freed = 0;
foreach (var orphan in analysis.Orphans)
{
try
{
var info = new FileInfo(orphan);
if (!info.Exists || info.LinkTarget is not null) continue;
var size = info.Length;
File.Delete(orphan);
deleted++;
freed += size;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
failed++;
logFailure(orphan, ex);
}
}
return new ObjectsPruneResult { GameId = key, Deleted = deleted, FreedBytes = freed, Failed = failed };
}
}
private sealed record PruneAnalysis(ObjectsPrunePreview Preview, IReadOnlyList<string> Orphans);
private PruneAnalysis Analyze(string manifestPath, string gameId)
{
var bytes = PublishedFiles.ReadManifest(manifestPath)
?? throw ApiRejection.NotFound($"Aucun manifest en ligne pour « {gameId} » : rien à nettoyer.");
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
var manifest = ManifestReferences.Parse(bytes);
if (!manifest.LocalObjects)
throw ApiRejection.Conflict("Le manifest en ligne sert ses fichiers depuis une autre adresse (« objectsBaseUrl ») : " +
"nettoyage impossible depuis le menu.");
var objectsDirectory = Path.Combine(gameDirectory, "objects");
if (new DirectoryInfo(gameDirectory).LinkTarget is not null || new DirectoryInfo(objectsDirectory).LinkTarget is not null)
throw ApiRejection.Conflict("Le dossier du jeu ou son dossier « objects » est un lien symbolique : nettoyage impossible depuis le menu.");
// Autres fichiers de description du site (un autre manifest peut désigner ces objets par une adresse) :
// un objet qu'ils utilisent, ou qu'ils rendent invérifiable, est conservé.
References? site = null;
if (_root is not null && IsInside(gameDirectory, _root))
{
site = ScanReferences();
if (site.SiteWide is { } reason) throw ApiRejection.Conflict($"Nettoyage impossible : {reason}");
}
var referenced = new HashSet<string>(manifest.Objects, StringComparer.Ordinal);
var present = new HashSet<string>(StringComparer.Ordinal);
var orphans = new List<string>();
long orphanBytes = 0;
if (Directory.Exists(objectsDirectory))
{
foreach (var prefix in new DirectoryInfo(objectsDirectory).EnumerateDirectories())
{
if (!PrefixRegex().IsMatch(prefix.Name) || prefix.LinkTarget is not null) continue;
foreach (var file in prefix.EnumerateFiles())
{
// Seuls les objets au nom exact (« <empreinte>.bin » dans le bon sous-dossier) sont concernés :
// tout autre fichier est laissé tel quel.
if (!ObjectNameRegex().IsMatch(file.Name) || !file.Name.StartsWith(prefix.Name, StringComparison.Ordinal) ||
file.LinkTarget is not null)
continue;
var sha = file.Name[..64];
if (referenced.Contains(sha))
{
present.Add(sha);
continue;
}
if (site is not null && site.StatusOf(file.FullName).Status != ServerFileStatus.Unused) continue;
orphans.Add(file.FullName);
orphanBytes += file.Length;
}
}
}
var inProgress = Directory.Exists(gameDirectory) &&
new DirectoryInfo(gameDirectory).EnumerateDirectories(StagingPrefix + "*", IncludingHidden).Any();
orphans.Sort(StringComparer.Ordinal);
return new PruneAnalysis(new ObjectsPrunePreview
{
GameId = gameId,
Version = manifest.Version ?? "",
ManifestSha256 = PublishedFiles.Sha256(bytes),
ReferencedCount = referenced.Count,
MissingCount = referenced.Count - present.Count,
OrphanCount = orphans.Count,
OrphanBytes = orphanBytes,
Examples = orphans.Take(10).Select(o => Path.GetRelativePath(gameDirectory, o).Replace('\\', '/')).ToList(),
PublicationInProgress = inProgress
}, orphans);
}
// ================================================================== Références (fichiers de description)
/// <summary>Dossiers parcourus au plus pour trouver les fichiers de description du site.</summary>
private const int MaxScannedFolders = 50_000;
/// <summary>
/// Fichiers désignés par TOUS les fichiers de description du site (manifest.json et launcher-update.json de
/// n'importe quel dossier, catalogue configuré), relus à chaque requête. Les adresses sont résolues comme le fait
/// le launcher (relatives au fichier de description, « .. » compris ; adresses absolues du même serveur rattachées
/// aux fichiers du site). Ce qui ne peut pas être vérifié n'est jamais supprimable.
/// </summary>
private sealed class References
{
public Dictionary<string, string> Files { get; } = new(PathComparer);
/// <summary>Dossiers dont le contenu ne peut pas être vérifié (fichier de description illisible…).</summary>
public List<(string Folder, string Reason)> UnknownFolders { get; } = new();
/// <summary>Vérification impossible pour tout le site (adresse du même serveur non rattachable, site trop grand…).</summary>
public string? SiteWide { get; set; }
public void Add(string path, string reason) => Files.TryAdd(path, reason);
public (string Status, string? Reason) StatusOf(string path)
{
var name = Path.GetFileName(path);
if (name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
return (ServerFileStatus.Protected, name.Equals(ManifestName, StringComparison.OrdinalIgnoreCase)
? "manifest d'un jeu : jamais supprimé."
: "fichier de description (.json : catalogue, trousseau, mise à jour du launcher…) : jamais supprimé depuis le menu.");
if (Files.TryGetValue(path, out var reason)) return (ServerFileStatus.InUse, reason);
if (SiteWide is not null) return (ServerFileStatus.Unknown, SiteWide);
foreach (var (folder, why) in UnknownFolders)
if (IsInside(path, folder))
return (ServerFileStatus.Unknown, why);
return (ServerFileStatus.Unused, null);
}
}
private References ScanReferences()
{
var root = RequireRoot();
var references = new References();
var site = new SiteAddresses(root, _catalogFile, _catalogUrl);
var folders = new List<string> { root };
try
{
// Dossiers cachés (dépôts « .publish-… ») et liens symboliques ignorés.
foreach (var directory in new DirectoryInfo(root).EnumerateDirectories("*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReparsePoint
}))
{
if (folders.Count >= MaxScannedFolders)
{
references.SiteWide = $"impossible de vérifier : le site contient plus de {MaxScannedFolders} dossiers.";
return references;
}
folders.Add(directory.FullName);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
references.SiteWide = "impossible de vérifier : le site n'a pas pu être parcouru entièrement.";
return references;
}
foreach (var folder in folders) ReadFolderReferences(folder, site, references);
if (_catalogFile is not null && IsInside(_catalogFile, root) && File.Exists(_catalogFile)) ReadCatalogReferences(site, references);
return references;
}
private void ReadFolderReferences(string folder, SiteAddresses site, References references)
{
var display = RelativeDisplay(folder);
var problems = new List<string>();
var manifest = Path.Combine(folder, ManifestName);
if (File.Exists(manifest))
{
try
{
var parsed = ManifestReferences.Parse(ReadDescription(manifest));
var version = parsed.Version is { Length: > 0 } v ? $"version {v}" : "version en ligne";
var reason = $"manifest de {display}";
if (parsed.Archive is { } archive &&
Resolve(site, folder, archive, $"archive « {archive} » du {reason}", problems, references) is { } archivePath)
references.Add(archivePath, $"archive de la {version} ({reason}).");
var objectsBase = string.IsNullOrEmpty(parsed.ObjectsBase) ? "objects/" : parsed.ObjectsBase;
if (!objectsBase.EndsWith('/')) objectsBase += "/";
if (parsed.Objects.Count > 0 &&
Resolve(site, folder, objectsBase, $"dossier des fichiers « {objectsBase} » du {reason}", problems, references) is { } objects)
foreach (var sha in parsed.Objects)
references.Add(Path.Combine(objects, sha[..2], sha + ".bin"), $"fichier de la {version} ({reason}).");
}
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
{
problems.Add($"manifest de {display} illisible ({ex.Message})");
}
}
var update = Path.Combine(folder, LauncherUpdateName);
if (File.Exists(update))
{
try
{
var (version, executable) = LauncherUpdateReference(ReadDescription(update));
if (Resolve(site, folder, executable, $"exécutable « {executable} » de launcher-update.json de {display}", problems, references) is { } target)
references.Add(target, $"exécutable du launcher {version} (launcher-update.json de {display}).");
}
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
{
problems.Add($"launcher-update.json de {display} illisible ({ex.Message})");
}
}
if (problems.Count > 0)
references.UnknownFolders.Add((folder,
$"impossible de vérifier : {string.Join(" ; ", problems)}. Rien n'est supprimé dans {display} tant que ce n'est pas corrigé."));
}
private void ReadCatalogReferences(SiteAddresses site, References references)
{
var folder = Path.GetDirectoryName(_catalogFile!)!;
try
{
foreach (var (gameId, kind, url) in CatalogImages(ReadDescription(_catalogFile!)))
{
switch (site.ResolveCatalogImage(url))
{
case { Path: { } path }:
references.Add(path, $"{kind} du jeu {gameId} dans le catalogue.");
break;
case { Unmappable: true }:
references.SiteWide ??= $"impossible de vérifier : {kind} du jeu {gameId} du catalogue (« {url} ») non rattachée aux fichiers du site.";
break;
}
}
}
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
{
references.UnknownFolders.Add((folder, $"impossible de vérifier : catalogue illisible ({ex.Message})."));
}
}
/// <summary>
/// Fichier (ou dossier) du site désigné par une adresse d'un fichier de description de <paramref name="folder"/>.
/// Null : hors du site (autre serveur, en dehors de la racine), ou non vérifiable (problème noté).
/// </summary>
private static string? Resolve(SiteAddresses site, string folder, string address, string what, List<string> problems, References references)
{
var resolved = site.Resolve(folder, address);
if (resolved.Invalid) problems.Add($"{what} : adresse non reconnue");
else if (resolved.Unmappable)
references.SiteWide ??= $"impossible de vérifier : {what} désigne ce serveur par une adresse complète qui n'a pas pu être rattachée aux fichiers du site.";
return resolved.Path;
}
/// <summary>Adresse résolue : chemin du site, adresse extérieure (rien), invalide, ou du même serveur mais non rattachable.</summary>
private readonly record struct ResolvedAddress(string? Path, bool Invalid = false, bool Unmappable = false);
/// <summary>
/// Correspondance entre adresses et fichiers du site. Hôte du site : celui du catalogue ; préfixe des adresses :
/// déduit de l'adresse publique et de l'emplacement du fichier du catalogue (« /catalog/catalog.json » ↔
/// « www/catalog/catalog.json » : la racine du site est servie sous « / »).
/// </summary>
private sealed class SiteAddresses
{
private const string LocalHost = "site.invalid";
private readonly string _root;
private readonly Uri? _catalogUri;
private readonly string? _catalogFile;
private readonly string? _sitePrefix;
public SiteAddresses(string root, string? catalogFile, string catalogUrl)
{
_root = root;
_catalogFile = catalogFile;
if (Uri.TryCreate(catalogUrl, UriKind.Absolute, out var uri)) _catalogUri = uri;
if (_catalogUri is not null && catalogFile is not null && IsInside(catalogFile, root))
{
var relative = "/" + System.IO.Path.GetRelativePath(root, catalogFile).Replace('\\', '/');
var path = Uri.UnescapeDataString(_catalogUri.AbsolutePath);
if (path.EndsWith(relative, StringComparison.Ordinal)) _sitePrefix = path[..^(relative.Length - 1)]; // se termine par « / »
}
}
/// <summary>Résolution comme le launcher : adresse relative au fichier de description (adresse fictive du dossier).</summary>
public ResolvedAddress Resolve(string folder, string address)
{
if (string.IsNullOrWhiteSpace(address) || address.Any(char.IsControl) || address.Contains('\\')) return new(null, Invalid: true);
var relativeFolder = System.IO.Path.GetRelativePath(_root, folder).Replace('\\', '/');
var baseUri = new Uri($"http://{LocalHost}/" + (relativeFolder == "." ? "" : string.Join('/', relativeFolder.Split('/').Select(Uri.EscapeDataString)) + "/"));
if (!Uri.TryCreate(baseUri, address, out var target) || target.Scheme is not ("http" or "https")) return new(null, Invalid: true);
if (target.Host == LocalHost) return Map(Uri.UnescapeDataString(target.AbsolutePath));
return Absolute(target);
}
/// <summary>Image du catalogue : relative à l'adresse du catalogue.</summary>
public ResolvedAddress ResolveCatalogImage(string url)
{
if (_catalogUri is null || _catalogFile is null) return new(null, Unmappable: true);
if (!Uri.TryCreate(_catalogUri, url, out var image) || image.Scheme is not ("http" or "https")) return new(null, Invalid: true);
if (!string.Equals(image.Host, _catalogUri.Host, StringComparison.OrdinalIgnoreCase)) return new(null);
if (_sitePrefix is not null) return Absolute(image);
// Préfixe inconnu : chemin relatif au dossier du catalogue.
var catalogPath = Uri.UnescapeDataString(_catalogUri.AbsolutePath);
var directory = catalogPath[..(catalogPath.LastIndexOf('/') + 1)];
var imagePath = Uri.UnescapeDataString(image.AbsolutePath);
if (!imagePath.StartsWith(directory, StringComparison.Ordinal)) return new(null, Unmappable: true);
var full = System.IO.Path.GetFullPath(System.IO.Path.Combine(System.IO.Path.GetDirectoryName(_catalogFile)!,
imagePath[directory.Length..].Replace('/', System.IO.Path.DirectorySeparatorChar)));
return IsInside(full, _root) ? new(full) : new(null);
}
private ResolvedAddress Absolute(Uri target)
{
if (_catalogUri is not null && !string.Equals(target.Host, _catalogUri.Host, StringComparison.OrdinalIgnoreCase))
return new(null); // autre serveur : rien à protéger ici
if (_sitePrefix is null) return new(null, Unmappable: true); // même serveur (ou inconnu) : impossible de rattacher
var path = Uri.UnescapeDataString(target.AbsolutePath);
return path.StartsWith(_sitePrefix, StringComparison.Ordinal) ? Map("/" + path[_sitePrefix.Length..]) : new(null);
}
/// <summary>Chemin d'adresse (« /jeu/objects/ab/… ») → chemin du site (null : en dehors de la racine).</summary>
private ResolvedAddress Map(string urlPath)
{
var relative = urlPath.TrimStart('/');
var full = System.IO.Path.GetFullPath(System.IO.Path.Combine(_root, relative.Replace('/', System.IO.Path.DirectorySeparatorChar)));
if (urlPath.EndsWith('/')) full = full.TrimEnd(System.IO.Path.DirectorySeparatorChar);
return IsInside(full, _root) ? new(full) : new(null);
}
}
private static byte[] ReadDescription(string path)
{
var info = new FileInfo(path);
if (info.Length > MaxDescriptionBytes) throw new InvalidDataException("fichier trop volumineux");
return File.ReadAllBytes(path);
}
private static (string Version, string Executable) LauncherUpdateReference(byte[] bytes)
{
var envelope = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "launcher-update.json");
var payload = PublishedFiles.StringValue(envelope, "payload") ?? throw new InvalidDataException("contenu signé absent");
byte[] decoded;
try
{
decoded = Convert.FromBase64String(payload);
}
catch (FormatException)
{
throw new InvalidDataException("contenu signé illisible");
}
var release = PublishedFiles.ParseObject(decoded, "Description du launcher");
var file = PublishedFiles.StringValue(release, "file");
if (string.IsNullOrWhiteSpace(file)) throw new InvalidDataException("exécutable absent");
return (PublishedFiles.StringValue(release, "version") ?? "", file);
}
private static IEnumerable<(string GameId, string Kind, string Url)> CatalogImages(byte[] bytes)
{
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Catalogue");
if (PublishedFiles.Property(root, "games") is not JsonObject games) yield break;
foreach (var (gameId, node) in games)
{
if (node is not JsonObject game) continue;
if (PublishedFiles.StringValue(game, "icon") is { Length: > 0 } icon) yield return (gameId, "icône", icon);
if (PublishedFiles.StringValue(game, "background") is { Length: > 0 } background) yield return (gameId, "image de fond", background);
}
}
// ================================================================== Chemins
private string RequireRoot() => _root ?? throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Racine du site non configurée sur le serveur (LauncherApi:Files:Root, ou un jeu / le catalogue configuré).");
/// <summary>
/// Chemin relatif (« / ») → chemin complet sous la racine. Refus : « .. », chemin absolu, « \ », « : »,
/// élément caché, caractère de contrôle, lien symbolique sur le chemin.
/// </summary>
private (string FullPath, string Relative) Resolve(string relativePath)
{
var root = RequireRoot();
var relative = relativePath.Trim().Trim('/');
if (relative.Length == 0) return (root, "");
if (relative.Length > ServerFilesLimits.MaxPathLength || relative.Any(char.IsControl) || relative.Contains('\\') ||
relative.Contains(':'))
throw ApiRejection.BadRequest("Chemin invalide.");
var segments = relative.Split('/');
if (segments.Any(s => s.Length == 0 || s == "." || s == ".." || s.StartsWith('.')))
throw ApiRejection.BadRequest("Chemin invalide (élément vide, caché ou « .. »).");
var full = Path.GetFullPath(Path.Combine(new[] { root }.Concat(segments).ToArray()));
if (!IsInside(full, root)) throw ApiRejection.BadRequest("Chemin en dehors du site.");
// Aucun lien symbolique entre la racine et l'élément (il pourrait mener en dehors du site).
var current = root;
foreach (var segment in segments)
{
current = Path.Combine(current, segment);
FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current);
if (info.Exists && info.LinkTarget is not null)
throw ApiRejection.BadRequest("Chemin refusé : il passe par un lien symbolique.");
}
return (full, string.Join('/', segments));
}
/// <summary>Dossiers de la racine jusqu'à <paramref name="folder"/> inclus (fichiers de description pris en compte).</summary>
private IEnumerable<string> FoldersFromRootTo(string folder)
{
var root = _root;
var chain = new List<string>();
for (var current = Path.GetFullPath(folder).TrimEnd(Path.DirectorySeparatorChar);
current is not null && (root is null || IsInside(current, root));
current = Path.GetDirectoryName(current))
chain.Add(current);
chain.Reverse();
return chain;
}
/// <summary>Jeux configurés dont le dossier contient ce chemin (verrous à prendre avant de supprimer).</summary>
private IEnumerable<string> GamesContaining(string path) =>
_manifests.Where(m => IsInside(path, Path.GetDirectoryName(m.Value)!))
.Select(m => m.Key).OrderBy(k => k, StringComparer.Ordinal).ToList();
/// <summary>Dossier d'un jeu (contenant un manifest.json) où une publication est en cours, parmi les parents du chemin.</summary>
private string? PublicationFolderInProgress(string path)
{
foreach (var folder in FoldersFromRootTo(Path.GetDirectoryName(path)!))
if (File.Exists(Path.Combine(folder, ManifestName)) || _manifests.Values.Any(m => string.Equals(Path.GetDirectoryName(m), folder, PathComparison)))
if (new DirectoryInfo(folder).EnumerateDirectories(StagingPrefix + "*", IncludingHidden).Any())
return folder;
return null;
}
private (string Path, string GameId) Locate(string gameId)
{
foreach (var (key, path) in _manifests)
if (string.Equals(key, gameId, StringComparison.OrdinalIgnoreCase))
return (path, key);
throw PublishedFiles.NotConfigured(gameId, "Nettoyage des fichiers");
}
private string RelativeDisplay(string folder)
{
if (_root is null) return Path.GetFileName(folder) + "/";
var relative = Path.GetRelativePath(_root, folder).Replace('\\', '/');
return relative == "." ? "la racine du site" : relative + "/";
}
private static bool IsInside(string path, string parent)
{
var p = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar);
var r = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar);
return string.Equals(p, r, PathComparison) || p.StartsWith(r + Path.DirectorySeparatorChar, PathComparison);
}
}
/// <summary>
/// Ce qu'un manifest.json désigne sur le disque : nom de l'archive, empreintes des fichiers. Lecture tolérante (tout
/// manifest du site, pas seulement ceux des jeux configurés) mais stricte sur ce qui sert à décider : toute empreinte
/// invalide rend le manifest « illisible », donc rien n'est supprimé à côté.
/// </summary>
internal sealed record ManifestReferences(string? Version, string? Archive, string? ObjectsBase, bool LocalObjects, IReadOnlyCollection<string> Objects)
{
public static ManifestReferences Parse(byte[] bytes)
{
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
var version = PublishedFiles.StringValue(root, "version");
string? archive = null;
if (PublishedFiles.Property(root, "archive") is JsonObject archiveNode)
archive = PublishedFiles.StringValue(archiveNode, "url") is { Length: > 0 } url ? url : throw new InvalidDataException("archive sans adresse");
var objectsBase = PublishedFiles.StringValue(root, "objectsBaseUrl");
var local = string.IsNullOrEmpty(objectsBase) || objectsBase is "objects/" or "objects";
var objects = new HashSet<string>(StringComparer.Ordinal);
if (PublishedFiles.Property(root, "files") is JsonArray files)
{
foreach (var node in files)
{
if (node is not JsonObject file) throw new InvalidDataException("fichier invalide dans la liste");
var sha = PublishedFiles.StringValue(file, "sha256")?.ToLowerInvariant();
if (!PublishingLimits.IsValidSha256(sha)) throw new InvalidDataException("empreinte de fichier invalide");
objects.Add(sha!);
}
}
else if (PublishedFiles.Property(root, "files") is not null)
{
throw new InvalidDataException("liste des fichiers invalide");
}
return new ManifestReferences(version, archive, objectsBase, local, objects);
}
}
@@ -45,6 +45,12 @@
// identifiant (GameId de launcher.json). À renseigner dans appsettings.Production.json sur le VPS
// (voir server/deploy/LISEZMOI.md), ex. « "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } ».
"Games": {},
// Page « Fichiers du serveur » : racine du site public consultable depuis le menu. Vide = dossier parent
// commun des jeux et du catalogue (ex. « /srv/exovival/www »). La suppression n'est possible que dans les
// dossiers autorisés en écriture par systemd (ReadWritePaths : jeux, catalogue). Voir server/deploy/LISEZMOI.md.
"Files": {
"Root": ""
},
"Limits": {
"RequestsPerMinute": 300,
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
+35
View File
@@ -290,6 +290,41 @@ La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
à la fois les mises à jour du launcher et le trousseau.
## 3 sexies. Fichiers du serveur : libérer de la place depuis le launcher (VPS)
La page **« Fichiers du serveur »** du menu d'administration (propriétaires et administrateurs, permission
`files.manage`) affiche le contenu du site public et permet de supprimer ce qui ne sert plus : anciennes archives
`.zip`, anciens exécutables du launcher, images retirées du catalogue, et les fichiers du dossier `objects` d'un jeu
que la version en ligne n'utilise plus (bouton **« Nettoyer le dossier objects »**, avec analyse préalable).
1. Racine du site : par défaut, le dossier parent commun des jeux et du catalogue configurés (ici
`/srv/exovival/www`). Pour un autre dossier, dans `appsettings.Production.json` :
```json
{ "LauncherApi": { "Files": { "Root": "/srv/exovival/www" } } }
```
Le dossier doit être séparé des données privées (`/var/lib/launcher-api`). Les éléments cachés (`.publish-…`)
et les liens symboliques ne sont ni affichés comme fichiers ordinaires, ni suivis, ni supprimés.
2. Écriture : rien à ajouter pour les dossiers des jeux et du catalogue (déjà dans `games.conf` et `catalog.conf`).
Pour supprimer aussi dans un autre dossier (ex. `/srv/exovival/www/launcher`, exécutables du launcher), ajoute
une ligne `ReadWritePaths=` à un complément systemd, puis `sudo systemctl daemon-reload && sudo systemctl restart launcher-api`.
Sans cela, la page affiche ces fichiers mais la suppression est refusée (« le serveur n'a pas le droit de modifier ce dossier »).
Règles appliquées par le serveur (le launcher ne fait qu'afficher) :
* les fichiers `.json` (manifest, catalogue, trousseau, `launcher-update.json`) ne sont **jamais** supprimés ;
* avant chaque suppression, le serveur relit **tous** les fichiers de description du site (chaque `manifest.json`
et `launcher-update.json`, le catalogue) : un fichier encore désigné (archive, fichier d'une version, exécutable,
image) est refusé, y compris par une adresse relative (`../`) ou complète vers ce serveur ;
* un fichier de description illisible rend son dossier non supprimable ; une adresse de ce serveur impossible à
rattacher aux fichiers du site bloque toute suppression (impossible de vérifier) ;
* vérification refaite sous le verrou d'écriture du manifest du jeu, et refus pendant une publication de ce jeu ;
* le nettoyage du dossier `objects` ne touche que les fichiers `<empreinte>.bin` que plus aucun manifest du site
n'utilise, et seulement si le manifest analysé n'a pas changé entre l'analyse et le nettoyage ;
* chaque suppression et chaque nettoyage sont inscrits au journal d'audit (`files.delete`, `files.prune`).
À savoir : après un nettoyage, une version précédente ne peut plus être republiée sans renvoyer ses fichiers
(la publication les renvoie automatiquement). Un joueur en train de télécharger l'ancienne version reprend
avec la nouvelle à sa prochaine vérification.
## 4. Créer ta clé d'administrateur (PC)
Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères minimum). Il crée :
+26 -6
View File
@@ -3,6 +3,7 @@ using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
@@ -114,12 +115,31 @@ public sealed class AdminSession : IAsyncDisposable
public Task<AdminSummary> RevokeAdminAsync(Guid adminId, CancellationToken ct = default) =>
SendSignedAsync<AdminSummary>(HttpMethod.Post, ApiRoutes.AdminRevoke(adminId), (object?)null, ct);
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, CancellationToken ct = default)
{
var route = $"{ApiRoutes.Audit}?limit={limit}";
if (!string.IsNullOrEmpty(cursor)) route += "&cursor=" + Uri.EscapeDataString(cursor);
return GetAsync<AuditPage>(route, ct);
}
/// <param name="before">Facultatif : seulement les entrées antérieures à cet instant (filtre « jusqu'au »).</param>
public Task<AuditPage> GetAuditAsync(int limit = 50, string? cursor = null, DateTimeOffset? before = null, CancellationToken ct = default) =>
GetAsync<AuditPage>(ApiRoutes.AuditPage(limit, cursor, before), ct);
/// <summary>Inscrit un export CSV du journal au journal (à faire AVANT d'écrire le fichier).</summary>
public Task RecordAuditExportAsync(AuditExportRequest request, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Post, ApiRoutes.AuditExport, request, ct);
// ------------------------------------------------------------------ Fichiers du site public (www)
/// <summary>Contenu d'un dossier du site (chemin relatif à sa racine, « / » comme séparateur ; « » : racine).</summary>
public Task<ServerFolder> GetServerFolderAsync(string path, CancellationToken ct = default) =>
GetAsync<ServerFolder>(ApiRoutes.FilesFolder(path), ct);
/// <summary>Supprime un fichier que le serveur juge inutilisé (refus s'il est référencé par un manifest).</summary>
public Task<ServerFileDeleteResult> DeleteServerFileAsync(string path, long size, CancellationToken ct = default) =>
SendSignedAsync<ServerFileDeleteResult>(HttpMethod.Post, ApiRoutes.FilesDelete, new ServerFileDeleteRequest { Path = path, Size = size }, ct);
public Task<ObjectsPrunePreview> GetObjectsPruneAsync(string gameId, CancellationToken ct = default) =>
GetAsync<ObjectsPrunePreview>(ApiRoutes.ObjectsPrune(CheckGameId(gameId)), ct);
/// <summary>Supprime les fichiers d'« objects » que le manifest analysé n'utilise plus (refus s'il a changé).</summary>
public Task<ObjectsPruneResult> PruneObjectsAsync(string gameId, string manifestSha256, CancellationToken ct = default) =>
SendSignedAsync<ObjectsPruneResult>(HttpMethod.Post, ApiRoutes.ObjectsPrune(CheckGameId(gameId)),
new ObjectsPruneRequest { ManifestSha256 = manifestSha256 }, ct);
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
+27
View File
@@ -40,6 +40,33 @@ public static class ApiRoutes
// ------------------------------------------------------------------ Audit
public const string Audit = "v1/audit";
/// <summary>Annonce d'un export CSV du journal (requête signée, inscrite au journal avant l'écriture du fichier).</summary>
public const string AuditExport = "v1/audit/export";
/// <summary>
/// Page du journal : <paramref name="before"/> (facultatif) = entrées antérieures à cet instant (filtre « jusqu'au »),
/// <paramref name="cursor"/> = suite de la page précédente.
/// </summary>
public static string AuditPage(int limit, string? cursor, DateTimeOffset? before)
{
var route = $"{Audit}?limit={limit}";
if (!string.IsNullOrEmpty(cursor)) route += $"&cursor={Uri.EscapeDataString(cursor)}";
if (before is { } date)
route += $"&before={Uri.EscapeDataString(date.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'", System.Globalization.CultureInfo.InvariantCulture))}";
return route;
}
// ------------------------------------------------------------------ Fichiers du site public (www)
public const string Files = "v1/files";
public const string FilesDelete = "v1/files/delete";
public const string ObjectsPrunePattern = "v1/games/{gameId}/objects/prune";
/// <summary>Contenu d'un dossier (chemin relatif à la racine du site, « » : racine).</summary>
public static string FilesFolder(string path) =>
string.IsNullOrEmpty(path) ? Files : $"{Files}?path={Uri.EscapeDataString(path)}";
public static string ObjectsPrune(string gameId) => $"v1/games/{gameId}/objects/prune";
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
public const string Catalog = "v1/catalog";
@@ -48,7 +48,7 @@ public sealed record CatalogLauncher
/// <summary>Informations modifiables d'un jeu (null : valeur de launcher.json).</summary>
public sealed record CatalogGame
{
/// <summary>Nom AFFICHÉ (le nom de launcher.json reste celui du raccourci, du registre et des dossiers).</summary>
/// <summary>Nom AFFICHÉ, repris par le raccourci du Bureau (le nom de launcher.json reste celui du registre et des dossiers).</summary>
public string? GameName { get; init; }
public string? Tagline { get; init; }
@@ -26,6 +26,30 @@ public sealed record AuditPage
public string? NextCursor { get; init; }
}
/// <summary>
/// Export CSV du journal par le menu d'administration (requête signée) : le launcher l'annonce au serveur AVANT
/// d'écrire le fichier, et n'écrit rien si le serveur ne l'a pas inscrit au journal (action « audit.export »).
/// </summary>
public sealed record AuditExportRequest
{
/// <summary>Nombre d'entrées exportées.</summary>
public int Count { get; init; }
/// <summary>Période exportée (jours inclus, heure du PC), null si non bornée.</summary>
public DateOnly? From { get; init; }
public DateOnly? To { get; init; }
/// <summary>Autres filtres appliqués (catégorie, résultat, recherche), en clair ; null si aucun.</summary>
public string? Filters { get; init; }
}
public static class AuditExportLimits
{
public const int MaxCount = 1_000_000;
public const int MaxFiltersLength = 300;
}
public static class AuditResults
{
public const string Ok = "ok";
@@ -0,0 +1,144 @@
namespace GameLauncher.Contracts.Files;
/// <summary>
/// Page « Fichiers du serveur » : contenu d'un dossier du site public (www), état de chaque fichier
/// vis-à-vis des manifests, suppression d'un fichier inutilisé, nettoyage du dossier « objects » d'un jeu.
/// Le serveur décide seul de ce qui peut être supprimé ; le launcher ne fait qu'afficher.
/// </summary>
public static class ServerFileStatus
{
/// <summary>Dossier (ouvrable, jamais supprimé depuis le menu).</summary>
public const string Directory = "directory";
/// <summary>Fichier de description (.json : manifest, catalogue, trousseau, mise à jour du launcher) : jamais supprimé.</summary>
public const string Protected = "protected";
/// <summary>Référencé par un manifest (archive, fichier de la version en ligne) ou par le catalogue (image).</summary>
public const string InUse = "in_use";
/// <summary>Référencé par aucun fichier de description : peut être supprimé.</summary>
public const string Unused = "unused";
/// <summary>Un fichier de description voisin est illisible : impossible de vérifier, donc jamais supprimé.</summary>
public const string Unknown = "unknown";
/// <summary>Lien symbolique : ni suivi, ni supprimé.</summary>
public const string Link = "link";
}
/// <summary>Un élément d'un dossier.</summary>
public sealed record ServerFileEntry
{
public string Name { get; init; } = "";
public bool IsDirectory { get; init; }
/// <summary>Taille en octets (dossier : total de son contenu ; -1 si trop de fichiers pour le calculer).</summary>
public long Size { get; init; }
/// <summary>Nombre de fichiers d'un dossier (-1 si non calculé).</summary>
public long FileCount { get; init; }
public DateTimeOffset Modified { get; init; }
/// <summary>Voir <see cref="ServerFileStatus"/>.</summary>
public string Status { get; init; } = ServerFileStatus.Unknown;
/// <summary>Explication de l'état (« Archive de la version 1.2.0 (manifest de exovivalzombie/) »…).</summary>
public string? Reason { get; init; }
}
/// <summary>Contenu d'un dossier du site public.</summary>
public sealed record ServerFolder
{
/// <summary>Chemin relatif à la racine du site (« » : racine ; séparateur « / »).</summary>
public string Path { get; init; } = "";
/// <summary>Nom affiché de la racine (dernier élément du chemin configuré, ex. « www »).</summary>
public string RootName { get; init; } = "";
public List<ServerFileEntry> Entries { get; init; } = new();
/// <summary>Le dossier contient plus d'éléments que la limite affichée.</summary>
public bool Truncated { get; init; }
/// <summary>Espace libre et taille du disque du site (-1 si inconnus).</summary>
public long FreeBytes { get; init; } = -1;
public long TotalBytes { get; init; } = -1;
/// <summary>Jeux dont le dossier « objects » peut être nettoyé (identifiants configurés sur le serveur).</summary>
public List<string> PrunableGames { get; init; } = new();
}
/// <summary>Suppression d'un fichier inutilisé (requête signée).</summary>
public sealed record ServerFileDeleteRequest
{
/// <summary>Chemin relatif à la racine du site (séparateur « / »).</summary>
public string Path { get; init; } = "";
/// <summary>Taille vue dans la liste : refus si le fichier a changé depuis (remplacé entre-temps).</summary>
public long Size { get; init; }
}
public sealed record ServerFileDeleteResult
{
public string Path { get; init; } = "";
public long FreedBytes { get; init; }
}
/// <summary>Analyse du dossier « objects » d'un jeu : fichiers que le manifest en ligne n'utilise plus.</summary>
public sealed record ObjectsPrunePreview
{
public string GameId { get; init; } = "";
/// <summary>Version du manifest en ligne.</summary>
public string Version { get; init; } = "";
/// <summary>Empreinte du manifest analysé : à renvoyer pour le nettoyage (refus s'il a changé entre-temps).</summary>
public string ManifestSha256 { get; init; } = "";
/// <summary>Fichiers de la version en ligne (objets distincts).</summary>
public int ReferencedCount { get; init; }
/// <summary>Fichiers de la version en ligne absents du serveur (information : à republier).</summary>
public int MissingCount { get; init; }
public int OrphanCount { get; init; }
public long OrphanBytes { get; init; }
/// <summary>Quelques fichiers inutilisés (aperçu).</summary>
public List<string> Examples { get; init; } = new();
/// <summary>Une publication de ce jeu est en cours : nettoyage impossible pour l'instant.</summary>
public bool PublicationInProgress { get; init; }
}
/// <summary>Nettoyage du dossier « objects » d'un jeu (requête signée).</summary>
public sealed record ObjectsPruneRequest
{
/// <summary><see cref="ObjectsPrunePreview.ManifestSha256"/> de l'analyse affichée.</summary>
public string ManifestSha256 { get; init; } = "";
}
public sealed record ObjectsPruneResult
{
public string GameId { get; init; } = "";
public int Deleted { get; init; }
public long FreedBytes { get; init; }
/// <summary>Fichiers qui n'ont pas pu être supprimés (droits) : le serveur l'explique dans son journal.</summary>
public int Failed { get; init; }
}
public static class ServerFilesLimits
{
/// <summary>Éléments renvoyés au plus pour un dossier.</summary>
public const int MaxEntries = 2000;
/// <summary>Fichiers parcourus au plus pour calculer la taille des sous-dossiers d'un dossier.</summary>
public const int MaxScannedFiles = 200_000;
public const int MaxPathLength = 1024;
}
@@ -22,8 +22,11 @@ public static class AdminPermissions
/// <summary>Publication d'une nouvelle version du jeu — phase 5.</summary>
public const string ReleasesPublish = "releases.publish";
/// <summary>Fichiers du site public (www) : consultation, suppression des fichiers inutilisés, nettoyage d'« objects ».</summary>
public const string FilesManage = "files.manage";
public static readonly IReadOnlyList<string> All = new[]
{
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage
};
}
@@ -6,7 +6,7 @@ public static class AdminRoles
/// <summary>Tous les droits, dont la gestion des administrateurs.</summary>
public const string Owner = "owner";
/// <summary>Contenu et publication, sans gestion des administrateurs.</summary>
/// <summary>Contenu, publication et fichiers du site, sans gestion des administrateurs.</summary>
public const string Admin = "admin";
/// <summary>Contenu uniquement (informations des jeux, notes de version).</summary>
@@ -21,7 +21,7 @@ public static class AdminRoles
[Admin] = new[]
{
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage
},
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
};
@@ -8,7 +8,7 @@ namespace GameLauncher.Core.Catalog;
/// <summary>
/// Applique le catalogue aux jeux déclarés dans launcher.json — affichage uniquement :
/// • jeux inconnus de launcher.json : ignorés (le serveur ne peut ni ajouter ni retirer un jeu) ;
/// • nom AFFICHÉ seulement (raccourci, registre et dossier des données gardent le nom de launcher.json) ;
/// • nom AFFICHÉ (repris par le raccourci du Bureau ; registre et dossier des données gardent le nom de launcher.json) ;
/// • thème : seulement un thème intégré au launcher ;
/// • images : seulement des fichiers du cache local (téléchargés et contrôlés par <see cref="CatalogService"/>) ;
/// • jeu masqué : toujours affiché s'il est installé sur le PC ou demandé (raccourci, désinstallation),
@@ -51,8 +51,8 @@ public sealed class LauncherConfig
// ------------------------------------------------------------------ Catalogue (affichage modifiable)
/// <summary>
/// Nom AFFICHÉ, donné par le catalogue. <see cref="GameName"/> reste le nom de launcher.json : celui
/// du raccourci du Bureau, de « Applications installées » et du dossier des données du jeu.
/// Nom AFFICHÉ, donné par le catalogue (repris par le nom du raccourci du Bureau). <see cref="GameName"/>
/// reste le nom de launcher.json : celui de « Applications installées » et du dossier des données du jeu.
/// </summary>
[JsonIgnore]
public string? DisplayName { get; set; }
@@ -48,7 +48,7 @@ public abstract class AccountsPanelBase : AdminPanelViewModel
protected AccountsPanelBase(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
}
public AsyncRelayCommand RefreshCommand { get; }
@@ -62,7 +62,9 @@ public abstract class AccountsPanelBase : AdminPanelViewModel
get => _statusMessage;
protected set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
if (!SetProperty(ref _statusMessage, value)) return;
OnPropertyChanged(nameof(HasStatus));
if (value is not null) RequestScrollToTop(); // résultat d'une action d'une liste : bandeau en haut de la page
}
}
@@ -8,8 +8,10 @@ namespace GameLauncher.Modules.Admin.Audit;
/// <summary>
/// Page « Journal d'audit » (audit.read) : actions enregistrées par le serveur, les plus récentes
/// d'abord, chargées par pages de 200 ; filtres (texte, catégorie, résultat) appliqués aux entrées
/// chargées ; export CSV de ce qui est affiché.
/// d'abord, chargées par pages de 200 ; filtres (période, texte, catégorie, résultat) ; export CSV
/// des entrées filtrées. Période : le serveur ne renvoie que les entrées antérieures à la fin de la
/// période, et les pages sont chargées jusqu'à son début (dans la limite de <see cref="MaxLoaded"/>).
/// Chaque consultation est inscrite au journal par le serveur ; chaque export y est inscrit avant l'écriture du fichier.
/// </summary>
public sealed class AuditPanelViewModel : AdminPanelViewModel
{
@@ -33,6 +35,14 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
private Choice _result;
private string? _lastRefresh;
private bool _hasLoaded;
private Choice _period;
private string _fromText = "";
private string _toText = "";
private string? _dateError;
private DateTime? _from; // début de la période (jour inclus, heure locale)
private DateTime? _to; // fin de la période (jour inclus, heure locale)
private bool _settingPeriod;
private bool _periodReloadPending;
public AuditPanelViewModel(IAdminContext context) : base(context)
{
@@ -45,6 +55,8 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
new("catalog.", "Informations des jeux"),
new("changelog.", "Notes de version"),
new("release.", "Publications"),
new("files.", "Fichiers du serveur"),
new("audit.", "Consultations et exports du journal"),
new("security", "Refus de sécurité")
};
Results = new List<Choice>
@@ -53,9 +65,20 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
new(AuditResults.Ok, "Réussis"),
new(AuditResults.Denied, "Refusés")
};
Periods = new List<Choice>
{
new(null, "Toutes les dates"),
new(PeriodToday, "Aujourd'hui"),
new(PeriodWeek, "7 derniers jours"),
new(PeriodMonth, "30 derniers jours"),
new(PeriodThisMonth, "Ce mois-ci"),
new(PeriodLastMonth, "Mois précédent"),
new(PeriodCustom, "Dates choisies")
};
_category = Categories[0];
_result = Results[0];
RefreshCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: true)), () => !IsBusy);
_period = Periods[0];
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(() => LoadAsync(reset: true))), () => !IsBusy);
LoadMoreCommand = new AsyncRelayCommand(() => RunAsync(() => LoadAsync(reset: false)), () => !IsBusy && CanLoadMore);
ClearFiltersCommand = new RelayCommand(ClearFilters, () => HasFilters);
ExportCommand = new AsyncRelayCommand(ExportAsync, () => !IsBusy && _entries.Count > 0);
@@ -63,6 +86,130 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
public IReadOnlyList<Choice> Categories { get; }
public IReadOnlyList<Choice> Results { get; }
public IReadOnlyList<Choice> Periods { get; }
private const string PeriodToday = "today", PeriodWeek = "7d", PeriodMonth = "30d", PeriodThisMonth = "this-month",
PeriodLastMonth = "last-month", PeriodCustom = "custom";
private const string DateFormat = "dd/MM/yyyy";
/// <summary>Aujourd'hui (heure locale) ; remplaçable par les tests.</summary>
internal Func<DateTime> Today { get; set; } = () => DateTime.Today;
/// <summary>Rechargement lancé par un changement de période (tests).</summary>
internal Task PeriodReload { get; private set; } = Task.CompletedTask;
/// <summary>Période rapide ; « Dates choisies » : champs « Du » / « Au ».</summary>
public Choice Period
{
get => _period;
set
{
if (value is null || !SetProperty(ref _period, value)) return;
if (value.Value == PeriodCustom) return; // les champs restent tels quels
var today = Today().Date;
var (from, to) = value.Value switch
{
PeriodToday => (today, today),
PeriodWeek => (today.AddDays(-6), today),
PeriodMonth => (today.AddDays(-29), today),
PeriodThisMonth => (new DateTime(today.Year, today.Month, 1), today),
PeriodLastMonth => (new DateTime(today.Year, today.Month, 1).AddMonths(-1), new DateTime(today.Year, today.Month, 1).AddDays(-1)),
_ => ((DateTime?)null, (DateTime?)null)
};
_settingPeriod = true;
FromText = from?.ToString(DateFormat, CultureInfo.InvariantCulture) ?? "";
ToText = to?.ToString(DateFormat, CultureInfo.InvariantCulture) ?? "";
_settingPeriod = false;
ApplyPeriod();
}
}
/// <summary>Début de la période (jj/mm/aaaa, inclus ; vide : depuis le début du journal).</summary>
public string FromText
{
get => _fromText;
set
{
if (!SetProperty(ref _fromText, value ?? "")) return;
if (!_settingPeriod) OnDateEdited();
}
}
/// <summary>Fin de la période (jj/mm/aaaa, inclus ; vide : jusqu'à maintenant).</summary>
public string ToText
{
get => _toText;
set
{
if (!SetProperty(ref _toText, value ?? "")) return;
if (!_settingPeriod) OnDateEdited();
}
}
/// <summary>Date saisie invalide (affichée sous les champs) ; la période précédente reste appliquée.</summary>
public string? DateError
{
get => _dateError;
private set
{
if (SetProperty(ref _dateError, value)) OnPropertyChanged(nameof(HasDateError));
}
}
public bool HasDateError => !string.IsNullOrEmpty(DateError);
public bool HasPeriod => _from is not null || _to is not null;
private void OnDateEdited()
{
if (_period.Value != PeriodCustom)
{
_period = Periods.First(p => p.Value == PeriodCustom);
OnPropertyChanged(nameof(Period));
}
ApplyPeriod();
}
/// <summary>Contrôle les champs ; période changée → rechargement depuis le serveur.</summary>
private void ApplyPeriod()
{
if (!TryParseDate(_fromText, out var from) || !TryParseDate(_toText, out var to))
{
DateError = "Date attendue au format jj/mm/aaaa (ex. 07/11/2026).";
return;
}
if (from is { } f && to is { } t && f > t)
{
DateError = "La date de début est postérieure à la date de fin.";
return;
}
DateError = null;
if (from == _from && to == _to) return;
_from = from;
_to = to;
OnPropertyChanged(nameof(HasPeriod));
OnPropertyChanged(nameof(HasFilters));
ClearFiltersCommand.RaiseCanExecuteChanged();
ApplyFilters();
if (IsBusy) _periodReloadPending = true; // rechargé dès la fin du chargement en cours
else if (_hasLoaded) PeriodReload = RunAsync(() => LoadAsync(reset: true));
}
private static bool TryParseDate(string text, out DateTime? date)
{
date = null;
if (string.IsNullOrWhiteSpace(text)) return true;
if (!DateTime.TryParseExact(text.Trim(), new[] { DateFormat, "d/M/yyyy" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) ||
parsed.Year < 2000)
return false;
date = parsed.Date;
return true;
}
/// <summary>Fin de la période en heure universelle (le lendemain du dernier jour, 0 h locale), pour le serveur.</summary>
private DateTimeOffset? Before => _to is { } to ? new DateTimeOffset(to.AddDays(1), TimeZoneInfo.Local.GetUtcOffset(to.AddDays(1))) : null;
private DateTimeOffset? After => _from is { } from ? new DateTimeOffset(from, TimeZoneInfo.Local.GetUtcOffset(from)) : null;
public IReadOnlyList<AuditItem> Entries
{
@@ -118,7 +265,7 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
}
}
public bool HasFilters => _searchText.Length > 0 || _category.Value is not null || _result.Value is not null;
public bool HasFilters => _searchText.Length > 0 || _category.Value is not null || _result.Value is not null || HasPeriod;
/// <summary>« 37 entrées affichées sur 200 chargées (depuis le 12/10/2026 à 08:14) ».</summary>
public string SummaryText
@@ -128,7 +275,8 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
if (_loaded.Count == 0) return "";
var oldest = _loaded[^1].Timestamp.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
var shown = HasFilters ? $"{_filtered.Count} entrée(s) sur {_loaded.Count} chargée(s)" : $"{_loaded.Count} entrée(s) chargée(s)";
return $"{shown}, depuis le {oldest}" + (_nextCursor is null ? " (début du journal)."
var period = _to is { } to ? $" jusqu'au {to.ToString(DateFormat, CultureInfo.InvariantCulture)}" : "";
return $"{shown}{period}, depuis le {oldest}" + (_nextCursor is null ? " (début du journal)."
: _loaded.Count >= MaxLoaded ? $". Limite de {MaxLoaded} entrées atteinte : les plus anciennes restent dans les fichiers du journal sur le VPS."
: ".");
}
@@ -153,6 +301,18 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
RefreshCommand.RaiseCanExecuteChanged();
LoadMoreCommand.RaiseCanExecuteChanged();
ExportCommand.RaiseCanExecuteChanged();
if (!IsBusy && _periodReloadPending)
{
_periodReloadPending = false;
PeriodReload = ReloadAfterCurrentAsync();
}
}
/// <summary>Période changée pendant un chargement : nouveau chargement juste après celui-ci.</summary>
private async Task ReloadAfterCurrentAsync()
{
await Task.Yield(); // après la fin du chargement en cours (fil de l'interface)
await RunAsync(() => LoadAsync(reset: true));
}
/// <summary>Chargé au premier affichage ; ensuite « Actualiser » (garde les pages déjà chargées et les filtres d'ici là).</summary>
@@ -160,10 +320,18 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
private async Task LoadAsync(bool reset)
{
var page = await Context.Session.GetAuditAsync(PageSize, reset ? null : _nextCursor);
var before = Before;
var page = await Context.Session.GetAuditAsync(PageSize, reset ? null : _nextCursor, before);
if (reset) _loaded.Clear();
_loaded.AddRange(page.Entries);
_nextCursor = page.NextCursor;
// Période : pages suivantes jusqu'à son début (l'export doit contenir toute la période chargée).
while (reset && After is { } after && _nextCursor is not null && _loaded.Count < MaxLoaded && _loaded[^1].Timestamp >= after)
{
page = await Context.Session.GetAuditAsync(PageSize, _nextCursor, before);
_loaded.AddRange(page.Entries);
_nextCursor = page.NextCursor;
}
_hasLoaded = true;
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
OnPropertyChanged(nameof(CanLoadMore));
@@ -179,13 +347,16 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
OnPropertyChanged(nameof(SearchText));
OnPropertyChanged(nameof(Category));
OnPropertyChanged(nameof(Result));
if (HasPeriod || _period.Value is not null || HasDateError) Period = Periods[0]; // recharge toutes les dates
ApplyFilters();
}
private void ApplyFilters()
{
var text = _searchText.Trim();
var (after, before) = (After, Before);
_filtered = _loaded
.Where(e => (after is null || e.Timestamp >= after) && (before is null || e.Timestamp < before))
.Where(e => AuditTexts.InCategory(e.Action, _category.Value))
.Where(e => _result.Value is null || e.Result == _result.Value)
.Select(e => new AuditItem(e))
@@ -202,14 +373,45 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
private async Task ExportAsync()
{
var period = (_from, _to) switch
{
(null, null) => "",
({ } f, null) => $"-depuis-{f:yyyy-MM-dd}",
(null, { } t) => $"-jusqu-au-{t:yyyy-MM-dd}",
({ } f, { } t) => $"-{f:yyyy-MM-dd}-au-{t:yyyy-MM-dd}"
};
var path = Context.Dialogs.PickSaveFile("Exporter le journal d'audit", "Fichier CSV (*.csv)|*.csv",
$"journal-audit-{DateTime.Now:yyyy-MM-dd-HHmm}.csv", null);
$"journal-audit{period}-{DateTime.Now:yyyy-MM-dd-HHmm}.csv", null);
if (path is null) return;
var entries = _filtered;
if (await RunAsync(() => File.WriteAllTextAsync(path, AuditTexts.ToCsv(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true))))
var announce = new AuditExportRequest
{
Count = entries.Count,
From = _from is { } from ? DateOnly.FromDateTime(from) : null,
To = _to is { } to ? DateOnly.FromDateTime(to) : null,
Filters = ExportFiltersText()
};
// L'export est inscrit au journal du serveur AVANT l'écriture du fichier : s'il ne peut pas l'être
// (serveur injoignable, session expirée, signature refusée), le fichier n'est pas écrit.
if (await RunAsync(async () =>
{
await Context.Session.RecordAuditExportAsync(announce);
await File.WriteAllTextAsync(path, AuditTexts.ToCsv(entries), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
}))
Context.Dialogs.ShowMessage("Journal exporté", $"{entries.Count} entrée(s) enregistrée(s) dans {Path.GetFileName(path)}.",
ModuleMessageKind.Success);
}
/// <summary>Filtres de l'export, en clair pour le journal (la période est transmise à part).</summary>
private string? ExportFiltersText()
{
var parts = new List<string>();
if (_category.Value is not null) parts.Add($"catégorie « {_category.Label} »");
if (_result.Value is not null) parts.Add($"résultat « {_result.Label} »");
var text = _searchText.Trim();
if (text.Length > 0) parts.Add($"recherche « {(text.Length > 80 ? text[..80] + "…" : text)} »");
return parts.Count == 0 ? null : string.Join(", ", parts);
}
}
/// <summary>Entrée du journal telle qu'affichée.</summary>
@@ -278,7 +480,11 @@ public static class AuditTexts
"release.start" => "Publication d'une version commencée",
"release.publish" => "Version mise en ligne",
"release.cancel" => "Publication abandonnée",
"files.delete" => "Fichier du serveur supprimé",
"files.prune" => "Dossier « objects » nettoyé",
"request.signature" => "Requête signée refusée",
"audit.view" => "Journal d'audit consulté",
"audit.export" => "Journal d'audit exporté (CSV)",
"audit.suppressed" => "Tentatives refusées regroupées",
_ => action
};
@@ -291,11 +497,15 @@ public static class AuditTexts
_ => result
};
/// <summary>Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées.</summary>
/// <summary>
/// Catégorie : préfixe de l'action (« auth. »…) ; « security » : refus de signature et tentatives regroupées ;
/// « audit. » : consultations et exports du journal (pas les tentatives regroupées, classées en sécurité).
/// </summary>
public static bool InCategory(string action, string? category) => category switch
{
null => true,
"security" => action is "request.signature" or "audit.suppressed",
"audit." => action is "audit.view" or "audit.export",
_ => action.StartsWith(category, StringComparison.Ordinal)
};
@@ -120,6 +120,7 @@ public sealed class GameEditor : CatalogEditor
Links.CollectionChanged += (_, _) =>
{
AddLinkCommand.RaiseCanExecuteChanged();
OnPropertyChanged(nameof(HasLinks));
Changed();
};
}
@@ -178,12 +179,16 @@ public sealed class GameEditor : CatalogEditor
}
public ObservableCollection<LinkEditor> Links { get; } = new();
/// <summary>Au moins un lien : les en-têtes des colonnes (type, texte, adresse) sont affichés.</summary>
public bool HasLinks => Links.Count > 0;
public IReadOnlyList<Choice> LinkKinds { get; }
public RelayCommand AddLinkCommand { get; }
// ------------------------------------------------------------------ Valeurs de launcher.json (rappel)
public string NameHint => $"Vide : « {Declared.GameName} ». Le raccourci et « Applications installées » gardent ce nom.";
public string NameHint => $"Vide : « {Declared.GameName} ». Le raccourci du Bureau prend le nom affiché (au démarrage du launcher, " +
"bouton « Raccourci » ou « Réparer ») ; « Applications installées » garde celui de launcher.json.";
public string TaglineHint => string.IsNullOrWhiteSpace(Declared.Tagline) ? "Vide : aucune accroche." : $"Vide : « {Declared.Tagline} ».";
public string WebsiteHint => string.IsNullOrWhiteSpace(Declared.WebsiteUrl) ? "Adresse https. Vide : aucun site." : $"Adresse https. Vide : {Declared.WebsiteUrl}";
public string IntervalHint => $"0 : désactivé, ou {CatalogRules.MinUpdateInterval} à {CatalogRules.MaxUpdateInterval} minutes. Vide : {Declared.UpdateCheckIntervalMinutes} (launcher.json).";
@@ -36,8 +36,8 @@ public sealed class GamesPanelViewModel : AdminPanelViewModel
foreach (var game in context.Games) Games.Add(new GameEditor(game, context.Themes, OnEdited));
RebuildItems(Launcher);
PublishCommand = new AsyncRelayCommand(PublishAsync, () => !IsBusy && HasChanges && PublishingEnabled);
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy);
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync), () => !IsBusy && HasChanges && PublishingEnabled);
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
MoveUpCommand = new RelayCommand(() => Move(-1), () => CanMove(-1));
MoveDownCommand = new RelayCommand(() => Move(+1), () => CanMove(+1));
}
@@ -6,6 +6,7 @@ using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Changelog;
@@ -13,6 +14,7 @@ namespace GameLauncher.Modules.Admin.Changelog;
/// <summary>
/// Page « Notes de version » : modification des notes des versions DÉJÀ publiées, dans le manifest.json
/// du serveur (la création d'une version reste l'affaire de ManifestBuilder et du .bat).
/// • plusieurs jeux déclarés : rien n'est chargé ni affiché avant le choix du jeu (aucun jeu par défaut) ;
/// • liste des versions (pastille « modifié »), éditeur en-tête › sections › éléments, aperçu en
/// direct avec les modèles d'affichage du launcher (Themes/Changelog.xaml) ;
/// • mêmes bornes que le launcher, vérifiées avant l'envoi (rien n'est tronqué en silence) ;
@@ -32,11 +34,11 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
public ChangelogPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
?? GameChoices.FirstOrDefault();
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && HasChanges && !HasConflict && _state is not null);
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy && _selectedGame is not null);
GameChoices = GameTile.Choices(context);
_selectedGame = GameTile.Initial(GameChoices);
GameTiles = GameTile.For(context, GameChoices, choice => SelectedGame = choice);
SaveCommand = new AsyncRelayCommand(ScrollingToTop(SaveAsync), () => !IsBusy && HasChanges && !HasConflict && _state is not null);
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy && _selectedGame is not null);
}
// ------------------------------------------------------------------ Jeu
@@ -44,12 +46,36 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
public IReadOnlyList<Choice> GameChoices { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
/// <summary>Tuiles du choix du jeu, affichées tant qu'aucun jeu n'est choisi.</summary>
public IReadOnlyList<GameTile> GameTiles { get; }
public bool HasSelectedGame => _selectedGame is not null;
public bool ShowGamePicker => _selectedGame is null;
/// <summary>Chargement lancé par le dernier choix de jeu (tests).</summary>
internal Task GameLoad { get; private set; } = Task.CompletedTask;
/// <summary>Liste « Jeu » en haut de la page, pour passer à un autre jeu une fois le premier choisi.</summary>
public bool ShowGameSwitcher => HasSeveralGames && _selectedGame is not null;
/// <summary>Pas de changement de jeu pendant un chargement ou un enregistrement.</summary>
public bool IsGameSwitchEnabled => !IsBusy;
public string GamePickerText => GameChoices.Count == 0
? "Aucun jeu n'est déclaré dans launcher.json."
: "Choisissez le jeu dont vous voulez modifier les notes de version.";
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame)) return;
if (IsBusy)
{
OnPropertyChanged(); // pas pendant un chargement ou un enregistrement
return;
}
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les notes modifiées de ce jeu n'ont pas été enregistrées : elles seront perdues.", "Abandonner et changer de jeu"))
{
@@ -58,8 +84,12 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
}
_selectedGame = value;
OnPropertyChanged();
foreach (var name in new[] { nameof(HasSelectedGame), nameof(ShowGamePicker), nameof(ShowGameSwitcher) })
OnPropertyChanged(name);
ReloadCommand.RaiseCanExecuteChanged();
ErrorMessage = null;
Clear(); // rien de l'ancien jeu ne reste affiché (ni enregistrable) si le chargement échoue
_ = LoadAsync();
GameLoad = LoadAsync();
}
}
@@ -126,6 +156,7 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
SaveCommand.RaiseCanExecuteChanged();
ReloadCommand.RaiseCanExecuteChanged();
}
@@ -138,6 +169,7 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
{
if (_selectedGame?.Value is not { } gameId) return;
var state = await Context.Session.GetChangelogAsync(gameId);
if (!string.Equals(_selectedGame?.Value, gameId, StringComparison.OrdinalIgnoreCase)) return; // jeu changé entre-temps
Apply(state);
StatusMessage = null;
});
@@ -0,0 +1,26 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Files;
/// <summary>
/// Module « Fichiers du serveur » : parcours du site public (www), suppression des fichiers inutilisés
/// (anciennes archives…) et nettoyage du dossier « objects » des jeux. Permission files.manage
/// (propriétaires, administrateurs).
/// </summary>
public sealed class FilesAdminModule : ILauncherModule
{
public string Id => "files";
public string DisplayName => "Fichiers du serveur";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
FilesPanelViewModel.PanelId,
"Fichiers du serveur",
"Contenu du site public. Libérez de la place en supprimant les fichiers qu'aucun manifest n'utilise plus " +
"(anciennes archives, anciens exécutables…) : le serveur vérifie les manifests avant chaque suppression.",
"Harddisk",
AdminPermissions.FilesManage,
Order: 35,
context => new FilesPanelViewModel(context)));
}
@@ -0,0 +1,402 @@
using System.Globalization;
using GameLauncher.Contracts.Files;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Files;
/// <summary>
/// Page « Fichiers du serveur » (files.manage) : parcours du site public (www), suppression d'un fichier que le
/// serveur juge inutilisé (après confirmation), nettoyage du dossier « objects » d'un jeu (analyse, puis
/// confirmation). Le serveur revérifie tout au moment de supprimer : la page ne fait qu'afficher ses réponses.
/// </summary>
public sealed class FilesPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "files.manage";
private ServerFolder? _folder;
private IReadOnlyList<ServerFileItem> _entries = Array.Empty<ServerFileItem>();
private IReadOnlyList<FolderCrumb> _crumbs = Array.Empty<FolderCrumb>();
private string _path = "";
private bool _showOnlyUnused;
private string? _statusMessage;
private IReadOnlyList<Choice> _pruneGames = Array.Empty<Choice>();
private Choice? _pruneGame;
private ObjectsPrunePreview? _prune;
public FilesPanelViewModel(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(() => LoadAsync(_path))), () => !IsBusy);
UpCommand = new AsyncRelayCommand(() => OpenAsync(Parent(_path)), () => !IsBusy && _path.Length > 0);
AnalyzeCommand = new AsyncRelayCommand(AnalyzeAsync, () => !IsBusy && _pruneGame is not null);
PruneCommand = new AsyncRelayCommand(ScrollingToTop(PruneAsync), () => !IsBusy && CanPrune);
}
// ------------------------------------------------------------------ Dossier affiché
public string RootName => _folder?.RootName is { Length: > 0 } name ? name : "www";
/// <summary>Chemin du dossier affiché (« » : racine du site).</summary>
public string CurrentPath => _path;
/// <summary>Fil d'Ariane : racine, puis chaque dossier jusqu'au dossier affiché.</summary>
public IReadOnlyList<FolderCrumb> Crumbs
{
get => _crumbs;
private set => SetProperty(ref _crumbs, value);
}
public IReadOnlyList<ServerFileItem> Entries
{
get => _entries;
private set
{
if (!SetProperty(ref _entries, value)) return;
OnPropertyChanged(nameof(EmptyText));
}
}
/// <summary>N'afficher que les fichiers supprimables (et les dossiers, pour continuer à parcourir).</summary>
public bool ShowOnlyUnused
{
get => _showOnlyUnused;
set
{
if (SetProperty(ref _showOnlyUnused, value)) ApplyFolder();
}
}
public string EmptyText => _folder is null || Entries.Count > 0 ? ""
: _showOnlyUnused ? "Aucun fichier inutilisé dans ce dossier." : "Dossier vide.";
/// <summary>« 12 éléments · 3 fichier(s) inutilisé(s) : 1,2 Go ».</summary>
public string SummaryText
{
get
{
if (_folder is null) return "";
var unused = _folder.Entries.Where(e => e.Status == ServerFileStatus.Unused).ToList();
var text = $"{_folder.Entries.Count} élément(s)";
if (unused.Count > 0) text += $" · {unused.Count} fichier(s) inutilisé(s) : {Bytes(unused.Sum(e => e.Size))}";
return text + (_folder.Truncated ? $" · seuls les {ServerFilesLimits.MaxEntries} premiers sont affichés" : "");
}
}
/// <summary>« Espace libre sur le serveur : 12,4 Go sur 40 Go ».</summary>
public string DiskText => _folder is { FreeBytes: >= 0, TotalBytes: > 0 } f
? $"Espace libre sur le serveur : {Bytes(f.FreeBytes)} sur {Bytes(f.TotalBytes)}"
: "";
/// <summary>Part utilisée du disque (0 à 1), pour la jauge.</summary>
public double DiskUsedRatio => _folder is { FreeBytes: >= 0, TotalBytes: > 0 } f ? 1 - (double)f.FreeBytes / f.TotalBytes : 0;
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand UpCommand { get; }
// ------------------------------------------------------------------ Nettoyage du dossier « objects »
/// <summary>Jeux configurés sur le serveur (seuls leurs dossiers « objects » peuvent être nettoyés).</summary>
public IReadOnlyList<Choice> PruneGames
{
get => _pruneGames;
private set
{
if (!SetProperty(ref _pruneGames, value)) return;
OnPropertyChanged(nameof(HasPruneGames));
}
}
public bool HasPruneGames => _pruneGames.Count > 0;
public Choice? PruneGame
{
get => _pruneGame;
set
{
if (!SetProperty(ref _pruneGame, value)) return;
Prune = null;
AnalyzeCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Dernière analyse (null : pas encore analysé, ou jeu changé).</summary>
public ObjectsPrunePreview? Prune
{
get => _prune;
private set
{
if (!SetProperty(ref _prune, value)) return;
OnPropertyChanged(nameof(HasPrune));
OnPropertyChanged(nameof(PruneText));
OnPropertyChanged(nameof(PruneDetail));
OnPropertyChanged(nameof(CanPrune));
OnPropertyChanged(nameof(PruneButtonText));
PruneCommand.RaiseCanExecuteChanged();
}
}
public bool HasPrune => _prune is not null;
public bool CanPrune => _prune is { OrphanCount: > 0, PublicationInProgress: false };
public string PruneButtonText => _prune is { OrphanCount: > 0 } p
? $"Supprimer {p.OrphanCount} fichier(s) ({Bytes(p.OrphanBytes)})"
: "Supprimer les fichiers inutilisés";
public string PruneText => _prune switch
{
null => "",
{ PublicationInProgress: true } p =>
$"Une publication de {p.GameId} est en cours : nettoyage possible une fois la version en ligne (ou la publication abandonnée).",
{ OrphanCount: 0 } p => $"Rien à nettoyer : les {p.ReferencedCount} fichier(s) du dossier « objects » sont tous utilisés par la version {p.Version}.",
var p => $"{p.OrphanCount} fichier(s) ne sont plus utilisés par la version en ligne ({p.Version}) : {Bytes(p.OrphanBytes)} à libérer."
};
public string PruneDetail => _prune switch
{
null => "",
{ MissingCount: > 0 } p => $"Attention : {p.MissingCount} fichier(s) de la version {p.Version} sont absents du serveur. Republiez la version.",
{ OrphanCount: > 0 } p => "Exemples : " + string.Join(", ", p.Examples.Take(3).Select(e => Shorten(e.Split('/').Last()))),
_ => ""
};
public AsyncRelayCommand AnalyzeCommand { get; }
public AsyncRelayCommand PruneCommand { get; }
protected override void OnBusyChanged()
{
RefreshCommand.RaiseCanExecuteChanged();
UpCommand.RaiseCanExecuteChanged();
AnalyzeCommand.RaiseCanExecuteChanged();
PruneCommand.RaiseCanExecuteChanged();
foreach (var entry in _entries) entry.RaiseCanExecuteChanged();
foreach (var crumb in _crumbs) crumb.OpenCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RunAsync(() => LoadAsync("")) : Task.CompletedTask;
// ------------------------------------------------------------------ Actions
/// <summary>Ouvre un dossier (fil d'Ariane, double-clic, « Remonter »).</summary>
internal async Task OpenAsync(string path)
{
StatusMessage = null;
if (await RunAsync(() => LoadAsync(path))) RequestScrollToTop();
}
private async Task LoadAsync(string path)
{
var folder = await Context.Session.GetServerFolderAsync(path);
_folder = folder;
_path = folder.Path;
Crumbs = BuildCrumbs(folder.RootName, folder.Path);
var games = folder.PrunableGames.Select(id => new Choice(id, GameLabel(id))).ToList();
if (!games.Select(g => g.Value).SequenceEqual(_pruneGames.Select(g => g.Value)))
{
PruneGames = games;
PruneGame = games.Count == 1 ? games[0] : null; // plusieurs jeux : choisi par l'administrateur, jamais par défaut
}
ApplyFolder();
OnPropertyChanged(nameof(CurrentPath));
OnPropertyChanged(nameof(RootName));
OnPropertyChanged(nameof(DiskText));
OnPropertyChanged(nameof(DiskUsedRatio));
UpCommand.RaiseCanExecuteChanged();
}
private void ApplyFolder()
{
if (_folder is null) return;
Entries = _folder.Entries
.Where(e => !_showOnlyUnused || e.IsDirectory || e.Status == ServerFileStatus.Unused)
.Select(e => new ServerFileItem(this, e))
.ToList();
OnPropertyChanged(nameof(SummaryText));
OnPropertyChanged(nameof(EmptyText));
}
internal async Task DeleteAsync(ServerFileItem item)
{
if (!item.CanDelete) return;
var path = Combine(_path, item.Name);
if (!Context.Dialogs.Confirm("Supprimer ce fichier",
$"« {item.Name} » ({item.SizeText}) va être supprimé définitivement du serveur.\n\n" +
"Le serveur vérifie d'abord qu'aucun manifest ne l'utilise : un fichier encore utilisé n'est jamais supprimé.",
"Supprimer", danger: true))
return;
StatusMessage = null;
ServerFileDeleteResult? result = null;
if (await RunAsync(async () => result = await Context.Session.DeleteServerFileAsync(path, item.Entry.Size)))
StatusMessage = $"« {item.Name} » supprimé : {Bytes(result!.FreedBytes)} libéré(s).";
RequestScrollToTop();
await ReloadKeepingErrorAsync(); // la liste reflète l'état réel du serveur
}
/// <summary>Recharge le dossier sans effacer l'erreur de l'action qui précède (refus du serveur).</summary>
private async Task ReloadKeepingErrorAsync()
{
var error = ErrorMessage;
await RunAsync(() => LoadAsync(_path));
if (error is not null && ErrorMessage is null) ErrorMessage = error;
}
private async Task AnalyzeAsync()
{
if (_pruneGame?.Value is not { } gameId) return;
Prune = null;
ObjectsPrunePreview? preview = null;
if (await RunAsync(async () => preview = await Context.Session.GetObjectsPruneAsync(gameId))) Prune = preview;
}
private async Task PruneAsync()
{
if (_prune is not { OrphanCount: > 0 } preview) return;
if (!Context.Dialogs.Confirm("Nettoyer le dossier « objects »",
$"{preview.OrphanCount} fichier(s) que la version en ligne de {preview.GameId} ({preview.Version}) n'utilise plus vont être " +
$"supprimés définitivement ({Bytes(preview.OrphanBytes)}).\n\n" +
"Les joueurs qui ont déjà la version en ligne ne sont pas concernés. Une version plus ancienne ne pourra plus être " +
"republiée sans renvoyer ces fichiers.",
"Supprimer", danger: true))
return;
StatusMessage = null;
ObjectsPruneResult? result = null;
if (await RunAsync(async () => result = await Context.Session.PruneObjectsAsync(preview.GameId, preview.ManifestSha256)))
StatusMessage = $"Dossier « objects » de {result!.GameId} nettoyé : {result.Deleted} fichier(s) supprimé(s), {Bytes(result.FreedBytes)} libéré(s)." +
(result.Failed > 0 ? $" {result.Failed} fichier(s) n'ont pas pu être supprimés (journal du serveur)." : "");
Prune = null;
await ReloadKeepingErrorAsync();
}
// ------------------------------------------------------------------ Outils
private string GameLabel(string gameId) =>
Context.Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase)) is { } game
? $"{game.GameName} ({gameId})"
: gameId;
private IReadOnlyList<FolderCrumb> BuildCrumbs(string rootName, string path)
{
var crumbs = new List<FolderCrumb> { new(this, string.IsNullOrEmpty(rootName) ? "www" : rootName, "") };
var current = "";
foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
current = Combine(current, segment);
crumbs.Add(new FolderCrumb(this, segment, current));
}
crumbs[^1].IsCurrent = true;
return crumbs;
}
internal static string Combine(string folder, string name) => folder.Length == 0 ? name : folder + "/" + name;
private static string Parent(string path) => path.LastIndexOf('/') is var i and > 0 ? path[..i] : "";
/// <summary>Taille lisible : « 812 o », « 4,2 Ko », « 1,3 Go ».</summary>
public static string Bytes(long bytes)
{
if (bytes < 0) return "?";
string[] units = { "o", "Ko", "Mo", "Go", "To" };
double value = bytes;
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
return unit == 0 ? $"{bytes} o" : value.ToString(value >= 100 ? "0" : "0.#", CultureInfo.InvariantCulture).Replace('.', ',') + " " + units[unit];
}
private static string Shorten(string name) => name.Length > 16 ? name[..12] + "…" : name;
}
/// <summary>Élément du fil d'Ariane.</summary>
public sealed class FolderCrumb
{
internal FolderCrumb(FilesPanelViewModel page, string label, string path)
{
Label = label;
Path = path;
OpenCommand = new AsyncRelayCommand(() => page.OpenAsync(path), () => !page.IsBusy && !IsCurrent);
}
public string Label { get; }
public string Path { get; }
public bool IsCurrent { get; internal set; }
public AsyncRelayCommand OpenCommand { get; }
}
/// <summary>Fichier ou dossier tel qu'affiché.</summary>
public sealed class ServerFileItem
{
internal ServerFileItem(FilesPanelViewModel page, ServerFileEntry entry)
{
Entry = entry;
Name = entry.Name;
IsDirectory = entry.IsDirectory && entry.Status == ServerFileStatus.Directory;
SizeText = entry.IsDirectory
? entry.Size >= 0 ? $"{FilesPanelViewModel.Bytes(entry.Size)} · {entry.FileCount} fichier(s)" : "taille non calculée (trop de fichiers)"
: FilesPanelViewModel.Bytes(entry.Size);
DateText = entry.Modified.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
OpenCommand = new AsyncRelayCommand(() => page.OpenAsync(FilesPanelViewModel.Combine(page.CurrentPath, entry.Name)),
() => IsDirectory && !page.IsBusy);
DeleteCommand = new AsyncRelayCommand(() => page.DeleteAsync(this), () => CanDelete && !page.IsBusy);
}
public ServerFileEntry Entry { get; }
public string Name { get; }
public bool IsDirectory { get; }
public string SizeText { get; }
public string DateText { get; }
public string Status => Entry.Status;
/// <summary>Supprimable : le serveur l'a jugé inutilisé (il le revérifie au moment de supprimer).</summary>
public bool CanDelete => Entry.Status == ServerFileStatus.Unused;
public string StatusText => Entry.Status switch
{
ServerFileStatus.Directory => "",
ServerFileStatus.Protected => "Protégé",
ServerFileStatus.InUse => "Utilisé",
ServerFileStatus.Unused => "Inutilisé",
ServerFileStatus.Link => "Lien",
_ => "Non vérifiable"
};
/// <summary>Explication (infobulle) : pourquoi le fichier est protégé ou utilisé.</summary>
public string ToolTipText => Entry.Status switch
{
ServerFileStatus.Directory => "Ouvrir le dossier",
ServerFileStatus.Unused => "Aucun manifest ni fichier de description n'utilise ce fichier : il peut être supprimé.",
_ => Capitalize(Entry.Reason ?? "Ce fichier ne peut pas être supprimé depuis le menu.")
};
public string IconKey => Entry.IsDirectory ? "Folder"
: Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ? "PackageVariant"
: Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? "FileDocument"
: Name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || Name.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ? "ImageOutline"
: "TextBoxOutline";
public AsyncRelayCommand OpenCommand { get; }
public AsyncRelayCommand DeleteCommand { get; }
internal void RaiseCanExecuteChanged()
{
OpenCommand.RaiseCanExecuteChanged();
DeleteCommand.RaiseCanExecuteChanged();
}
private static string Capitalize(string text) => text.Length > 0 ? char.ToUpper(text[0], CultureInfo.InvariantCulture) + text[1..] : text;
}
@@ -5,6 +5,7 @@ using GameLauncher.Contracts.Signing;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Publishing;
@@ -14,7 +15,9 @@ namespace GameLauncher.Modules.Admin.Publishing;
/// du dossier, puis envoi au serveur et mise en ligne (remplace l'envoi avec WinSCP).
/// • seuls les fichiers absents du serveur sont envoyés ; l'archive par morceaux ;
/// • « Interrompre » garde ce qui a été envoyé : « Publier » reprend là où l'envoi s'est arrêté ;
/// • le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète.
/// • le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète ;
/// • plusieurs jeux déclarés : rien n'est affiché avant le choix du jeu (aucun jeu par défaut) ; un envoi
/// interrompu reste attaché à son jeu (bandeau « Reprendre / Abandonner » quand on revient à ce jeu).
/// </summary>
public sealed class PublishPanelViewModel : AdminPanelViewModel
{
@@ -29,19 +32,20 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
private bool _isPublishing, _committing;
private double _progressValue;
private PublicationState? _interrupted;
private readonly Dictionary<string, PublicationState> _interruptedByGame = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _cancellation;
public PublishPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
?? GameChoices.FirstOrDefault();
ChooseCommand = new AsyncRelayCommand(ChooseAsync, () => CanEdit);
RecheckCommand = new AsyncRelayCommand(() => InspectAsync(_manifestPath!), () => CanEdit && _manifestPath is not null);
PublishCommand = new AsyncRelayCommand(PublishAsync, () => CanEdit && _release is not null);
GameChoices = GameTile.Choices(context);
_selectedGame = GameTile.Initial(GameChoices);
GameTiles = GameTile.For(context, GameChoices, choice => SelectedGame = choice);
ChooseCommand = new AsyncRelayCommand(ChooseAsync, () => CanEdit && _selectedGame is not null);
RecheckCommand = new AsyncRelayCommand(() => InspectAsync(_manifestPath!), () => CanEdit && _selectedGame is not null && _manifestPath is not null);
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync), () => CanEdit && _release is not null);
CancelCommand = new RelayCommand(() => _cancellation?.Cancel(), () => _isPublishing && !_committing);
SignOnlineCommand = new AsyncRelayCommand(SignOnlineAsync, () => CanEdit && CanSignOnline);
AbandonCommand = new AsyncRelayCommand(AbandonAsync, () => CanEdit && _interrupted is not null);
SignOnlineCommand = new AsyncRelayCommand(ScrollingToTop(SignOnlineAsync), () => CanEdit && CanSignOnline);
AbandonCommand = new AsyncRelayCommand(ScrollingToTop(AbandonAsync), () => CanEdit && _interrupted is not null);
}
// ------------------------------------------------------------------ Jeu, dossier
@@ -49,16 +53,56 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
public IReadOnlyList<Choice> GameChoices { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
/// <summary>Tuiles du choix du jeu, affichées tant qu'aucun jeu n'est choisi.</summary>
public IReadOnlyList<GameTile> GameTiles { get; }
public bool HasSelectedGame => _selectedGame is not null;
public bool ShowGamePicker => _selectedGame is null;
/// <summary>Lecture de la version en ligne lancée par le dernier choix de jeu (tests).</summary>
internal Task GameLoad { get; private set; } = Task.CompletedTask;
/// <summary>Liste « Jeu » en haut de la page, pour passer à un autre jeu une fois le premier choisi.</summary>
public bool ShowGameSwitcher => HasSeveralGames && _selectedGame is not null;
/// <summary>Pas de changement de jeu pendant un contrôle ou un envoi.</summary>
public bool IsGameSwitchEnabled => CanEdit;
public string GamePickerText => GameChoices.Count == 0
? "Aucun jeu n'est déclaré dans launcher.json."
: "Choisissez le jeu dont vous voulez publier une version.";
/// <summary>Nom du jeu choisi (titres, confirmations) : « Exovival Zombie Reborn (EZR) ».</summary>
public string SelectedGameLabel => _selectedGame?.Label ?? "";
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame) || _isPublishing) return;
if (value is null || Equals(value, _selectedGame) || _isPublishing || IsBusy)
{
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
return;
}
_selectedGame = value;
OnPropertyChanged();
foreach (var name in new[] { nameof(HasSelectedGame), nameof(ShowGamePicker), nameof(ShowGameSwitcher), nameof(SelectedGameLabel) })
OnPropertyChanged(name);
// Rien de l'ancien jeu ne reste affiché ni utilisable : dossier, version en ligne, signature, avancement.
ErrorMessage = null;
StatusMessage = null;
ManifestPath = null;
SetRelease(null);
_ = RefreshPublishedAsync();
_publishedVersion = null;
_online = null;
PublishedText = "";
ProgressText = null;
ProgressValue = 0;
SpeedText = null;
SetInterrupted(_interruptedByGame.GetValueOrDefault(value.Value ?? ""), remember: false);
RefreshCommands();
GameLoad = RefreshPublishedAsync();
}
}
@@ -114,6 +158,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
if (!SetProperty(ref _isPublishing, value)) return;
OnPropertyChanged(nameof(HasInterrupted));
OnPropertyChanged(nameof(CanEdit));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
OnPropertyChanged(nameof(ShowBusyBar));
RefreshCommands();
}
@@ -202,6 +247,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
{
if (_release is not { } release || _selectedGame?.Value is not { } gameId || _online is not { } online) return;
if (!Context.Dialogs.Confirm($"Signer la version en ligne {online.Version} ?",
$"Jeu : {SelectedGameLabel}.\n\n" +
"Vous attestez que la version en ligne est bien celle de ce dossier de publication (le serveur vérifie que les fichiers, " +
"l'archive et l'exécutable sont identiques). Les launchers des joueurs l'accepteront ensuite.", "Signer"))
return;
@@ -218,14 +264,17 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
private async Task RefreshSignatureAsync(string gameId)
{
ManifestSignatureState? online;
try
{
_online = await Context.Session.GetManifestSignatureAsync(gameId);
online = await Context.Session.GetManifestSignatureAsync(gameId);
}
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
{
_online = null; // serveur antérieur à la phase 7, jeu non configuré… : rien à afficher
online = null; // serveur antérieur à la phase 7, jeu non configuré… : rien à afficher
}
if (!IsSelected(gameId)) return; // jeu changé pendant la requête : réponse d'un autre jeu ignorée
_online = online;
NotifySignature();
}
@@ -240,6 +289,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(CanEdit));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
OnPropertyChanged(nameof(ShowBusyBar));
RefreshCommands();
}
@@ -277,7 +327,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
_ => $"version en ligne : {_publishedVersion}"
};
if (!Context.Dialogs.Confirm($"Publier la version {release.Summary.Version} ?",
$"Les joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
$"Jeu : {SelectedGameLabel}.\n\nLes joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
"Les fichiers absents du serveur sont envoyés, puis le manifest est mis en ligne en dernier.", "Publier"))
return;
@@ -299,7 +349,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
SetInterrupted(null);
_publishedVersion = result.Version;
PublishedText = $"Version en ligne : {result.Version}";
StatusMessage = $"Version {result.Version} publiée ({result.ObjectsAdded} fichier(s) envoyé(s)" +
StatusMessage = $"Version {result.Version} de {SelectedGameLabel} publiée ({result.ObjectsAdded} fichier(s) envoyé(s)" +
(result.ArchiveAdded ? " et l'archive" : "") +
"). Les launchers la proposeront à leur prochaine recherche de mise à jour.";
UpdateWarnings();
@@ -353,22 +403,26 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
try
{
var state = await Context.Session.GetChangelogAsync(gameId);
if (!IsSelected(gameId)) return; // jeu changé pendant la requête : réponse d'un autre jeu ignorée
_publishedVersion = state.ManifestVersion;
PublishedText = $"Version en ligne : {Display(state.ManifestVersion)}";
await RefreshSignatureAsync(gameId);
}
catch (ApiException ex) when (ex.Status == 404)
{
if (!IsSelected(gameId)) return;
_publishedVersion = "";
PublishedText = "Aucune version en ligne pour l'instant.";
}
catch (ApiException ex) when (ex.Code == ApiErrorCodes.NotConfigured)
{
if (!IsSelected(gameId)) return;
_publishedVersion = null;
PublishedText = "Publication non configurée sur le serveur pour ce jeu (LauncherApi:Games, voir server/deploy/LISEZMOI.md).";
}
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
{
if (!IsSelected(gameId)) return;
_publishedVersion = null;
PublishedText = $"Version en ligne inconnue : {ApiErrorText.Describe(ex)}";
}
@@ -407,8 +461,14 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
if (state.Resumed) ProgressText = "Reprise de l'envoi interrompu…";
}
private void SetInterrupted(PublicationState? state)
/// <param name="remember">Faux au changement de jeu : l'état affiché est celui déjà mémorisé pour ce jeu.</param>
private void SetInterrupted(PublicationState? state, bool remember = true)
{
if (remember && _selectedGame?.Value is { } gameId)
{
if (state is null) _interruptedByGame.Remove(gameId);
else _interruptedByGame[gameId] = state;
}
_interrupted = state;
OnPropertyChanged(nameof(HasInterrupted));
OnPropertyChanged(nameof(InterruptedText));
@@ -439,5 +499,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
SignOnlineCommand.RaiseCanExecuteChanged();
}
private bool IsSelected(string gameId) => string.Equals(_selectedGame?.Value, gameId, StringComparison.OrdinalIgnoreCase);
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -12,6 +12,7 @@ public static class AdminTexts
AdminPermissions.GamesEdit => "Informations des jeux",
AdminPermissions.ChangelogEdit => "Notes de version",
AdminPermissions.ReleasesPublish => "Publication des versions",
AdminPermissions.FilesManage => "Fichiers du serveur",
_ => permission
};
@@ -1,3 +1,4 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using GameLauncher.ApiClient;
@@ -31,6 +32,50 @@ public sealed record TpmLockoutInfo(int Failures, int MaxTries, TimeSpan Recover
public TimeSpan MaxWait => !IsLockedOut || !HealsWithTime
? TimeSpan.Zero
: RecoveryInterval * Math.Max(1, Failures - MaxTries + 1);
// Propriétés TPM 2.0 lues (TPM_PT_*, partie 2 de la spécification, § 6.13).
private const uint PtPermanent = 0x200, PtLockoutCounter = 0x20E, PtMaxAuthFail = 0x20F, PtLockoutInterval = 0x210;
/// <summary>
/// TPMA_PERMANENT.inLockout (bit 9) : la puce refuse tout code. Ne pas confondre avec le bit 2
/// (lockoutAuthSet), qui indique seulement qu'un mot de passe « lockout » existe — toujours vrai
/// sur un PC dont Windows gère la puce.
/// </summary>
internal const uint PermanentInLockout = 1u << 9;
/// <summary>
/// Lit la réponse à TPM2_GetCapability(TPM_CAP_TPM_PROPERTIES, TPM_PT_PERMANENT…) : en-tête (balise,
/// taille, code retour), moreData, capability, puis la liste (propriété, valeur), en gros-boutiste.
/// Null si la réponse est en erreur, incomplète ou ne contient pas le compteur, le maximum et l'intervalle.
/// </summary>
public static TpmLockoutInfo? FromCapabilityResponse(ReadOnlySpan<byte> response)
{
const int HeaderSize = 10, ListOffset = HeaderSize + 1 + 4 + 4, EntrySize = 8;
if (response.Length < ListOffset) return null;
if (BinaryPrimitives.ReadUInt16BigEndian(response) != 0x8001) return null; // TPM_ST_NO_SESSIONS
if (BinaryPrimitives.ReadUInt32BigEndian(response[6..]) != 0) return null; // code retour TPM
if (BinaryPrimitives.ReadUInt32BigEndian(response[(HeaderSize + 1)..]) != 6) return null; // TPM_CAP_TPM_PROPERTIES
var count = BinaryPrimitives.ReadUInt32BigEndian(response[(HeaderSize + 5)..]);
uint? permanent = null, counter = null, maxTries = null, interval = null;
for (var i = 0L; i < count && ListOffset + (i + 1) * EntrySize <= response.Length; i++)
{
var entry = response.Slice(ListOffset + (int)i * EntrySize, EntrySize);
var value = BinaryPrimitives.ReadUInt32BigEndian(entry[4..]);
switch (BinaryPrimitives.ReadUInt32BigEndian(entry))
{
case PtPermanent: permanent = value; break;
case PtLockoutCounter: counter = value; break;
case PtMaxAuthFail: maxTries = value; break;
case PtLockoutInterval: interval = value; break; // secondes
}
}
if (counter is null || maxTries is null || interval is null) return null;
var inLockout = permanent is { } attributes && (attributes & PermanentInLockout) != 0;
return new TpmLockoutInfo((int)Math.Min(counter.Value, int.MaxValue), (int)Math.Min(maxTries.Value, int.MaxValue),
TimeSpan.FromSeconds(interval.Value), inLockout);
}
}
/// <summary>Code PIN refusé par la puce, ou puce bloquée par sa protection anti-essais.</summary>
@@ -25,9 +25,9 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
public SigningPanelViewModel(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(() => RunAsync(ReloadAsync), () => !IsBusy);
ExportRequestCommand = new AsyncRelayCommand(ExportRequestAsync, () => !IsBusy && _state is not null && NeedsNewKeyring);
PublishSignedCommand = new AsyncRelayCommand(PublishSignedAsync, () => !IsBusy && _state is { KeyringPublishingEnabled: true });
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
ExportRequestCommand = new AsyncRelayCommand(ScrollingToTop(ExportRequestAsync), () => !IsBusy && _state is not null && NeedsNewKeyring);
PublishSignedCommand = new AsyncRelayCommand(ScrollingToTop(PublishSignedAsync), () => !IsBusy && _state is { KeyringPublishingEnabled: true });
}
// ------------------------------------------------------------------ Trousseau publié
@@ -28,7 +28,7 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
Permissions = profile.Permissions.Select(AdminTexts.Permission).ToList();
Games = context.Games.Select(g => new DashboardGame(g.GameName, g.GameId,
Uri.TryCreate(g.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : g.ManifestUrl)).ToList();
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsBusy);
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(RefreshAsync), () => !IsBusy);
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
}
@@ -0,0 +1,45 @@
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.ViewModels;
/// <summary>
/// Choix du jeu d'une page qui ne concerne qu'un jeu à la fois (« Notes de version », « Publier une version ») :
/// une tuile par jeu déclaré dans launcher.json. Avec plusieurs jeux, la page n'affiche rien d'autre tant qu'aucune
/// tuile n'a été choisie (pas de jeu par défaut : pas de modification ni de publication sur le mauvais jeu par mégarde).
/// </summary>
public sealed class GameTile
{
private GameTile(Choice choice, string name, string detail, Action<Choice> select)
{
Choice = choice;
Name = name;
Detail = detail;
SelectCommand = new RelayCommand(() => select(choice));
}
public Choice Choice { get; }
public string Name { get; }
/// <summary>« Identifiant EZR · serveur cdn.exemple.fr ».</summary>
public string Detail { get; }
public RelayCommand SelectCommand { get; }
/// <summary>Choix « Nom (identifiant) » des jeux déclarés.</summary>
public static IReadOnlyList<Choice> Choices(IAdminContext context) =>
context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
/// <summary>Jeu choisi d'office : le seul déclaré, sinon aucun (l'administrateur choisit).</summary>
public static Choice? Initial(IReadOnlyList<Choice> choices) => choices.Count == 1 ? choices[0] : null;
public static IReadOnlyList<GameTile> For(IAdminContext context, IReadOnlyList<Choice> choices, Action<Choice> select) =>
choices.Select(choice =>
{
var game = context.Games.FirstOrDefault(g => string.Equals(g.GameId, choice.Value, StringComparison.OrdinalIgnoreCase));
var host = game is not null && Uri.TryCreate(game.ManifestUrl, UriKind.Absolute, out var uri) ? uri.Host : null;
return new GameTile(choice, game?.GameName ?? choice.Label,
$"Identifiant {choice.Value}" + (string.IsNullOrEmpty(host) ? "" : $" · serveur {host}"), select);
}).ToList();
}
@@ -38,6 +38,33 @@ public abstract class AdminPanelViewModel : ObservableObject
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
/// <summary>
/// Change à chaque demande de retour en haut de la page (la fenêtre fait défiler la page jusqu'en haut) :
/// après « Recharger », « Publier »… le bandeau du résultat, en haut, est visible sans remonter à la main.
/// </summary>
public int ScrollToTopToken
{
get => _scrollToTopToken;
private set => SetProperty(ref _scrollToTopToken, value);
}
private int _scrollToTopToken;
protected void RequestScrollToTop() => ScrollToTopToken = unchecked(_scrollToTopToken + 1);
/// <summary>Action d'un bouton « Recharger », « Publier »… : retour en haut de la page au clic et une fois terminée.</summary>
protected Func<Task> ScrollingToTop(Func<Task> action) => async () =>
{
RequestScrollToTop();
try
{
await action();
}
finally
{
RequestScrollToTop();
}
};
/// <summary>Appelé à chaque affichage de la page ; <paramref name="firstTime"/> au premier.</summary>
public Task ActivateAsync()
{
@@ -67,7 +94,10 @@ public abstract class AdminPanelViewModel : ObservableObject
if (ApiErrorText.IsSessionLost(ex))
Context.ReportSessionLost("Session expirée ou révoquée : reconnectez-vous.");
else
{
ErrorMessage = ApiErrorText.Describe(ex);
RequestScrollToTop(); // bandeau d'erreur en haut de la page
}
return false;
}
finally
+2
View File
@@ -18,6 +18,7 @@ using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Files;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Platform;
@@ -568,6 +569,7 @@ public partial class App : Application, ILauncherRestartHost
new CatalogAdminModule(),
new ChangelogAdminModule(),
new PublishAdminModule(),
new FilesAdminModule(),
new AccountsAdminModule(),
new SigningAdminModule(),
new AuditAdminModule()
+143
View File
@@ -0,0 +1,143 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
namespace GameLauncher.Controls;
/// <summary>
/// Texte indicatif d'un champ vide (« https://… », « jj/mm/aaaa »…), affiché par les styles
/// ModernTextBoxStyle / ModernMultilineTextBoxStyle (Themes/Controls.xaml) tant que le champ est vide.
/// </summary>
public static class TextAssist
{
public static readonly DependencyProperty PlaceholderProperty = DependencyProperty.RegisterAttached(
"Placeholder", typeof(string), typeof(TextAssist), new FrameworkPropertyMetadata(null));
public static string? GetPlaceholder(DependencyObject element) => (string?)element.GetValue(PlaceholderProperty);
public static void SetPlaceholder(DependencyObject element, string? value) => element.SetValue(PlaceholderProperty, value);
}
/// <summary>
/// Retour en haut d'un <see cref="ScrollViewer"/> : chaque changement de <c>ScrollToTopToken</c> (demandé par la page
/// après « Recharger », « Publier »…) ou de <c>ResetOn</c> (page affichée) remonte la page, une fois la mise en page faite.
/// </summary>
public static class ScrollAssist
{
public static readonly DependencyProperty ScrollToTopTokenProperty = DependencyProperty.RegisterAttached(
"ScrollToTopToken", typeof(int), typeof(ScrollAssist), new PropertyMetadata(0, OnScrollRequested));
public static int GetScrollToTopToken(DependencyObject element) => (int)element.GetValue(ScrollToTopTokenProperty);
public static void SetScrollToTopToken(DependencyObject element, int value) => element.SetValue(ScrollToTopTokenProperty, value);
public static readonly DependencyProperty ResetOnProperty = DependencyProperty.RegisterAttached(
"ResetOn", typeof(object), typeof(ScrollAssist), new PropertyMetadata(null, OnScrollRequested));
public static object? GetResetOn(DependencyObject element) => element.GetValue(ResetOnProperty);
public static void SetResetOn(DependencyObject element, object? value) => element.SetValue(ResetOnProperty, value);
private static void OnScrollRequested(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not ScrollViewer viewer) return;
viewer.ScrollToTop();
// Contenu remplacé au même moment (rechargement) : encore une fois après la mise en page.
viewer.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(viewer.ScrollToTop));
}
}
/// <summary>
/// Molette de la souris dans le menu d'administration : elle fait défiler ce qui est SOUS le curseur, jamais une
/// liste déroulante fermée (pas de paramètre changé par mégarde en faisant défiler la page), ni un élément qui
/// garderait la molette sans pouvoir défiler (liste, champ texte) : la page défile alors à sa place.
/// Une liste déroulante OUVERTE garde son comportement normal (défilement de ses choix).
/// </summary>
public static class WheelAssist
{
public static readonly DependencyProperty IsEnabledProperty = DependencyProperty.RegisterAttached(
"IsEnabled", typeof(bool), typeof(WheelAssist), new PropertyMetadata(false, OnIsEnabledChanged));
public static bool GetIsEnabled(DependencyObject element) => (bool)element.GetValue(IsEnabledProperty);
public static void SetIsEnabled(DependencyObject element, bool value) => element.SetValue(IsEnabledProperty, value);
/// <summary>Défilement d'un cran de molette (comme WPF : 3 lignes de 16 pixels par défaut).</summary>
private static double PixelsPerNotch => Math.Max(1, SystemParameters.WheelScrollLines) * 16.0;
private static void OnIsEnabledChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not UIElement root) return;
root.PreviewMouseWheel -= OnPreviewMouseWheel;
if (e.NewValue is true) root.PreviewMouseWheel += OnPreviewMouseWheel;
}
private static void OnPreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
if (e.Handled || sender is not UIElement root || e.Delta == 0) return;
var source = e.OriginalSource as DependencyObject;
// Liste déroulante ouverte (son contenu est dans une fenêtre surgissante) : comportement normal.
if (source is Visual sourceVisual && PresentationSource.FromVisual(sourceVisual) is { } sourcePresentation &&
!ReferenceEquals(sourcePresentation, PresentationSource.FromVisual(root)))
return;
if (Ancestor<ComboBox>(source) is { IsDropDownOpen: true }) return;
// Élément sous le curseur (pas forcément celui qui a le focus clavier).
var hit = root.InputHitTest(e.GetPosition(root)) as DependencyObject;
if (hit is null) return;
if (Ancestor<ComboBox>(hit) is { IsDropDownOpen: true }) return;
var target = ScrollableAncestor(hit, e.Delta);
var overClosedComboBox = Ancestor<ComboBox>(source) is { IsDropDownOpen: false } || Ancestor<ComboBox>(hit) is { IsDropDownOpen: false };
if (!overClosedComboBox && ReferenceEquals(Ancestor<ScrollViewer>(source), target)) return; // comportement normal
e.Handled = true; // la liste déroulante fermée ou l'élément qui ne peut pas défiler ne reçoit pas la molette
if (target is null) return;
if (target.CanContentScroll)
{
for (var i = 0; i < Math.Max(1, SystemParameters.WheelScrollLines); i++)
if (e.Delta > 0) target.LineUp();
else target.LineDown();
}
else
{
target.ScrollToVerticalOffset(target.VerticalOffset - e.Delta * PixelsPerNotch / Mouse.MouseWheelDeltaForOneLine);
}
}
/// <summary>Premier ScrollViewer parent qui peut encore défiler dans le sens de la molette.</summary>
private static ScrollViewer? ScrollableAncestor(DependencyObject? element, int delta)
{
for (var current = element; current is not null; current = Parent(current))
if (current is ScrollViewer viewer && viewer.ScrollableHeight > 0 &&
(delta > 0 ? viewer.VerticalOffset > 0 : viewer.VerticalOffset < viewer.ScrollableHeight))
return viewer;
return null;
}
private static T? Ancestor<T>(DependencyObject? element) where T : DependencyObject
{
for (var current = element; current is not null; current = Parent(current))
if (current is T match)
return match;
return null;
}
private static DependencyObject? Parent(DependencyObject element) =>
element is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(element) ?? LogicalTreeHelper.GetParent(element)
: LogicalTreeHelper.GetParent(element); // Run, Hyperlink… (éléments de texte)
}
/// <summary>
/// Barre d'actions d'une page du menu d'administration, affichée SOUS la zone qui défile (toujours visible) :
/// modèle « AdminFooter.&lt;type de la page&gt; » de Themes/Admin.xaml, sinon rien.
/// </summary>
public sealed class AdminFooterTemplateSelector : DataTemplateSelector
{
public override DataTemplate? SelectTemplate(object? item, DependencyObject container)
{
if (item is null || container is not FrameworkElement element) return null;
return element.TryFindResource("AdminFooter." + item.GetType().Name) as DataTemplate
?? element.TryFindResource("AdminFooter.None") as DataTemplate;
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>2.0.0</Version>
<Version>2.3.0</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
+2 -22
View File
@@ -245,28 +245,8 @@ public sealed class TpmKeyStore : IAdminTpm
var response = new byte[512];
var length = (uint)response.Length;
if (Tbsip_Submit_Command(context, 0, 200, command, (uint)command.Length, response, ref length) != 0) return null;
var data = response.AsSpan(0, (int)Math.Min(length, (uint)response.Length));
if (data.Length < 19 || BinaryPrimitives.ReadUInt32BigEndian(data[6..]) != 0) return null; // code retour TPM
if (BinaryPrimitives.ReadUInt32BigEndian(data[11..]) != 6) return null;
var count = BinaryPrimitives.ReadUInt32BigEndian(data[15..]);
uint? permanent = null, counter = null, maxTries = null, interval = null;
for (var i = 0; i < count && 19 + (i + 1) * 8 <= data.Length; i++)
{
var entry = data.Slice(19 + i * 8, 8);
var value = BinaryPrimitives.ReadUInt32BigEndian(entry[4..]);
switch (BinaryPrimitives.ReadUInt32BigEndian(entry))
{
case 0x200: permanent = value; break; // TPM_PT_PERMANENT
case 0x20E: counter = value; break; // TPM_PT_LOCKOUT_COUNTER
case 0x20F: maxTries = value; break; // TPM_PT_MAX_AUTH_FAIL
case 0x210: interval = value; break; // TPM_PT_LOCKOUT_INTERVAL (secondes)
}
}
if (counter is null || maxTries is null || interval is null) return null;
var inLockout = permanent is { } attributes && (attributes & (1u << 2)) != 0; // TPMA_PERMANENT.inLockout
return new TpmLockoutInfo((int)Math.Min(counter.Value, int.MaxValue), (int)Math.Min(maxTries.Value, int.MaxValue),
TimeSpan.FromSeconds(interval.Value), inLockout);
// Analyse dans TpmLockoutInfo (testée) : compteur, maximum, intervalle et TPMA_PERMANENT.inLockout.
return TpmLockoutInfo.FromCapabilityResponse(response.AsSpan(0, (int)Math.Min(length, (uint)response.Length)));
}
catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or ArgumentOutOfRangeException)
{
+17 -1
View File
@@ -86,6 +86,9 @@ public sealed record DesktopShortcut(string FilePath, string TargetPath, bool Po
/// <summary>Icône du raccourci (« chemin,index »), telle qu'enregistrée.</summary>
public string? IconLocation { get; init; }
/// <summary>Commentaire du raccourci : nom du jeu posé par le launcher à sa création.</summary>
public string? Description { get; init; }
}
public interface IShortcutService
@@ -93,6 +96,9 @@ public interface IShortcutService
/// <summary>Emplacement du launcher en cours d'exécution.</summary>
string LauncherPath { get; }
/// <summary>Nom donné au raccourci (sans « .lnk ») : nom affiché du jeu (catalogue), sinon celui de launcher.json.</summary>
string ShortcutName { get; }
/// <summary>Crée le raccourci du Bureau (icône du jeu si installé). Faux en cas d'échec (journalisé).</summary>
bool CreateDesktopShortcut();
@@ -106,8 +112,18 @@ public interface IShortcutService
/// <summary>Raccourcis du Bureau (dossier de l'utilisateur) qui ouvrent ce jeu.</summary>
IReadOnlyList<DesktopShortcut> FindDesktopShortcuts();
/// <summary>Redirige le raccourci vers le launcher actuel. Faux en cas d'échec (journalisé).</summary>
/// <summary>
/// Redirige le raccourci vers le launcher actuel (et lui donne le nom affiché du jeu s'il porte un
/// nom posé par le launcher). Faux en cas d'échec (journalisé).
/// </summary>
bool RetargetDesktopShortcut(DesktopShortcut shortcut);
/// <summary>
/// Raccourcis de ce jeu vers ce launcher, nommés par le launcher : renommés selon le nom affiché
/// actuel du jeu (jamais par-dessus un fichier existant). Renvoie le nombre de raccourcis renommés.
/// </summary>
int RenameDesktopShortcuts();
/// <summary>Supprime les raccourcis de ce jeu nommés par le launcher (ceux renommés par le joueur restent).</summary>
void RemoveDesktopShortcut();
}
+126 -10
View File
@@ -15,6 +15,11 @@ namespace GameLauncher.Services;
/// ses raccourcis sont redirigés vers le nouvel emplacement (au démarrage si l'ancien exécutable
/// a disparu, ou depuis « Réparer »).
///
/// Nom : celui AFFICHÉ du jeu (catalogue publié depuis le menu d'administration), sinon celui de
/// launcher.json. Un raccourci nommé par le launcher suit ce nom (démarrage, bouton « Raccourci »,
/// « Réparer ») ; un raccourci renommé par le joueur garde son nom. Un fichier existant n'est jamais
/// remplacé par un nom venu du catalogue (au pire, le nom de launcher.json est conservé).
///
/// Icône : celle de l'exécutable du jeu (emplacement lu dans le registre), sinon celle du
/// launcher (jeu pas encore installé, exécutable absent). Une icône choisie par le joueur est
/// conservée ; celle que le launcher a posée suit le jeu s'il est déplacé.
@@ -43,7 +48,15 @@ public sealed class ShortcutService : IShortcutService
private static string DesktopDirectory => Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
private string ShortcutPath => Path.Combine(DesktopDirectory, PathHelper.SanitizeFolderName(_config.GameName) + ".lnk");
/// <summary>Nom du raccourci (sans « .lnk ») : nom affiché du jeu, sinon celui de launcher.json.</summary>
public string ShortcutName => PathHelper.SanitizeFolderName(_config.ShownName);
/// <summary>Nom tiré de launcher.json (raccourcis créés avant le catalogue, repli).</summary>
private string DeclaredShortcutName => PathHelper.SanitizeFolderName(_config.GameName);
private string ShortcutPath => LinkPath(ShortcutName);
private static string LinkPath(string name) => Path.Combine(DesktopDirectory, name + ".lnk");
/// <summary>
/// Exécutable du jeu installé (registre : dossier d'installation + exécutable), s'il existe ;
@@ -81,18 +94,27 @@ public sealed class ShortcutService : IShortcutService
try
{
var icon = DesiredIconLocation;
WithShell(shell => WithLink(shell, ShortcutPath, link =>
var path = ShortcutPath;
var declaredPath = LinkPath(DeclaredShortcutName);
// Nom venu du catalogue : jamais au détriment d'un raccourci qui n'est pas celui de ce jeu.
if (!SamePath(path, declaredPath) && File.Exists(path) && !IsShortcutForThisGame(path))
{
Log.Warn($"Un autre raccourci « {Path.GetFileName(path)} » existe déjà sur le Bureau : nom de launcher.json utilisé.");
path = declaredPath;
}
WithShell(shell => WithLink(shell, path, link =>
{
link.TargetPath = _launcherPath;
link.Arguments = Arguments;
link.WorkingDirectory = Path.GetDirectoryName(_launcherPath);
link.IconLocation = icon;
link.Description = _config.GameName;
link.Description = _config.ShownName; // nom posé par le launcher (voir IsLauncherNamed)
link.Save();
return true;
}));
NotifyShellUpdated(ShortcutPath);
Log.Info($"Raccourci créé : {ShortcutPath} (icône : {icon})");
NotifyShellUpdated(path);
Log.Info($"Raccourci créé : {path} (icône : {icon})");
return true;
}
catch (Exception ex)
@@ -118,9 +140,21 @@ public sealed class ShortcutService : IShortcutService
// Icône posée par le launcher : celle du jeu, à son emplacement actuel.
RefreshIcon(shortcut);
// Nom affiché du jeu modifié depuis le menu d'administration : le raccourci le suit.
if (shortcut.PointsToThisLauncher) RenameIfLauncherNamed(shortcut);
}
}
public int RenameDesktopShortcuts()
{
var renamed = 0;
foreach (var shortcut in FindDesktopShortcuts())
if (shortcut.PointsToThisLauncher && RenameIfLauncherNamed(shortcut))
renamed++;
return renamed;
}
public IReadOnlyList<DesktopShortcut> FindDesktopShortcuts()
{
var found = new List<DesktopShortcut>();
@@ -150,10 +184,11 @@ public sealed class ShortcutService : IShortcutService
string? target;
string? arguments;
string? icon;
string? description;
try
{
(target, arguments, icon) = WithLink(shell, file,
link => ((string?)link.TargetPath, (string?)link.Arguments, (string?)link.IconLocation));
(target, arguments, icon, description) = WithLink(shell, file,
link => ((string?)link.TargetPath, (string?)link.Arguments, (string?)link.IconLocation, (string?)link.Description));
}
catch (Exception ex)
{
@@ -164,7 +199,8 @@ public sealed class ShortcutService : IShortcutService
if (string.IsNullOrWhiteSpace(target) || !IsForThisGame(arguments)) continue;
found.Add(new DesktopShortcut(file, target, SamePath(target, _launcherPath), File.Exists(target))
{
IconLocation = icon
IconLocation = icon,
Description = description
});
}
return true;
@@ -196,16 +232,96 @@ public sealed class ShortcutService : IShortcutService
}));
NotifyShellUpdated(shortcut.FilePath);
Log.Info($"Raccourci « {shortcut.Name} » mis à jour : {shortcut.TargetPath} → {_launcherPath}");
return true;
}
catch (Exception ex)
{
Log.Error($"Mise à jour du raccourci impossible ({shortcut.FilePath})", ex);
return false;
}
RenameIfLauncherNamed(shortcut); // non bloquant : le raccourci fonctionne déjà
return true;
}
public void RemoveDesktopShortcut() => FileUtil.TryDeleteFile(ShortcutPath);
public void RemoveDesktopShortcut()
{
// Nom de launcher.json : comportement historique. Puis les raccourcis de ce jeu nommés par le
// launcher (nom affiché du catalogue…) ; ceux renommés par le joueur sont conservés.
FileUtil.TryDeleteFile(LinkPath(DeclaredShortcutName));
foreach (var shortcut in FindDesktopShortcuts())
if (IsLauncherNamed(shortcut))
FileUtil.TryDeleteFile(shortcut.FilePath);
}
/// <summary>
/// Vrai si le nom du raccourci a été posé par le launcher : nom de launcher.json, nom affiché actuel,
/// ou nom enregistré dans son commentaire lors de sa création. Faux s'il a été renommé par le joueur.
/// </summary>
private bool IsLauncherNamed(DesktopShortcut shortcut)
{
var name = shortcut.Name;
return string.Equals(name, DeclaredShortcutName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, ShortcutName, StringComparison.OrdinalIgnoreCase) ||
(!string.IsNullOrWhiteSpace(shortcut.Description) &&
string.Equals(name, PathHelper.SanitizeFolderName(shortcut.Description), StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Raccourci nommé par le launcher : renommé selon le nom affiché actuel du jeu. Jamais par-dessus
/// un fichier existant. Vrai si le raccourci a été renommé.
/// </summary>
private bool RenameIfLauncherNamed(DesktopShortcut shortcut)
{
var desired = ShortcutPath;
if (SamePath(shortcut.FilePath, desired) || !IsLauncherNamed(shortcut)) return false;
if (File.Exists(desired))
{
Log.Info($"Raccourci « {shortcut.Name} » non renommé : « {Path.GetFileName(desired)} » existe déjà sur le Bureau.");
return false;
}
try
{
File.Move(shortcut.FilePath, desired);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Raccourci « {shortcut.Name} » non renommé : {ex.Message}");
return false;
}
try
{
// Commentaire = nom posé par le launcher : le prochain changement de nom le reconnaîtra.
WithShell(shell => WithLink(shell, desired, link =>
{
link.Description = _config.ShownName;
link.Save();
return true;
}));
}
catch (Exception ex)
{
Log.Warn($"Commentaire du raccourci « {Path.GetFileName(desired)} » non mis à jour : {ex.Message}");
}
NotifyShellUpdated(desired);
Log.Info($"Raccourci renommé : « {shortcut.Name} » → « {ShortcutName} »");
return true;
}
/// <summary>Vrai si le fichier .lnk ouvre ce jeu (arguments « --game &lt;GameId&gt; »).</summary>
private bool IsShortcutForThisGame(string path)
{
try
{
return WithShell(shell => WithLink(shell, path, link => IsForThisGame((string?)link.Arguments)));
}
catch (Exception ex)
{
Log.Warn($"Lecture du raccourci impossible ({path}) : {ex.Message}");
return false;
}
}
/// <summary>Icône posée par le launcher remplacée par celle du jeu (à son emplacement actuel).</summary>
private void RefreshIcon(DesktopShortcut shortcut)
+564 -135
View File
@@ -7,7 +7,8 @@
xmlns:apub="clr-namespace:GameLauncher.Modules.Admin.Publishing;assembly=GameLauncher.Modules.Admin"
xmlns:aacc="clr-namespace:GameLauncher.Modules.Admin.Accounts;assembly=GameLauncher.Modules.Admin"
xmlns:aaud="clr-namespace:GameLauncher.Modules.Admin.Audit;assembly=GameLauncher.Modules.Admin"
xmlns:asig="clr-namespace:GameLauncher.Modules.Admin.Signing;assembly=GameLauncher.Modules.Admin">
xmlns:asig="clr-namespace:GameLauncher.Modules.Admin.Signing;assembly=GameLauncher.Modules.Admin"
xmlns:afil="clr-namespace:GameLauncher.Modules.Admin.Files;assembly=GameLauncher.Modules.Admin">
<!--
Menu d'administration (Views/Admin) : styles communs et modèles des pages des modules.
@@ -84,6 +85,41 @@
<Setter Property="Fill" Value="{DynamicResource Accent2Brush}" />
</Style>
<!-- Tuile cliquable du choix du jeu (pages « Notes de version » et « Publier une version ») -->
<Style x:Key="AdminGameTileStyle" TargetType="Button">
<Setter Property="Width" Value="270" />
<Setter Property="Margin" Value="0,0,12,12" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="14" Padding="14,12" BorderThickness="1" SnapsToDevicePixels="True"
Background="Transparent" BorderBrush="{DynamicResource BorderStrongBrush}">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource BadgeBrush}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.8" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Avatar rond avec les initiales -->
<Style x:Key="AdminAvatarStyle" TargetType="Border">
<Setter Property="Width" Value="40" />
@@ -129,6 +165,41 @@
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- ============================================================ Listes (navigation, clés, jeux, versions) -->
<!--
ListBox sans le modèle Windows : celui-ci peint un fond BLANC quand la liste est désactivée (page en
cours de rechargement) et impose un texte noir aux éléments. Ici : fond transparent, texte du thème,
liste simplement atténuée lorsqu'elle est désactivée.
-->
<Style x:Key="AdminListBoxStyle" TargetType="ListBox">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBox">
<Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}" SnapsToDevicePixels="True">
<ScrollViewer Focusable="False" Padding="{TemplateBinding Padding}"
HorizontalScrollBarVisibility="{TemplateBinding ScrollViewer.HorizontalScrollBarVisibility}"
VerticalScrollBarVisibility="{TemplateBinding ScrollViewer.VerticalScrollBarVisibility}">
<ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
</ScrollViewer>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.6" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ============================================================ Navigation (pages des modules) -->
<Style x:Key="AdminNavItemStyle" TargetType="ListBoxItem">
<Setter Property="Margin" Value="0,0,0,4" />
@@ -197,7 +268,8 @@
BorderBrush="{DynamicResource Accent2Brush}" BorderThickness="1" Visibility="Collapsed">
<TextBlock Text="TPM" FontSize="10" FontWeight="Bold" Foreground="{DynamicResource Accent2Brush}" />
</Border>
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" />
</DockPanel>
<TextBlock Text="{Binding Detail}" FontSize="11.5" Margin="0,2,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" />
@@ -424,15 +496,6 @@
</StackPanel>
</Grid>
</Border>
<!-- Actualisation -->
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Text="{Binding LastRefresh}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding HasPendingApprovals}" Value="True">
@@ -464,7 +527,8 @@
Data="{Binding IconKey, Converter={StaticResource IconKey}}" />
</Border>
<StackPanel Grid.Column="1" Margin="11,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Text="{Binding Subtitle}" FontSize="11.5" Margin="0,1,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
@@ -545,10 +609,8 @@
<!-- Launcher, puis les jeux dans l'ordre d'affichage -->
<StackPanel>
<ListBox ItemsSource="{Binding Items}" SelectedItem="{Binding Selected, Mode=TwoWay}"
ItemContainerStyle="{StaticResource AdminCatalogItemStyle}"
Background="Transparent" BorderThickness="0" Padding="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Disabled" />
Style="{StaticResource AdminListBoxStyle}"
ItemContainerStyle="{StaticResource AdminCatalogItemStyle}" />
<DockPanel Margin="2,4,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
@@ -565,26 +627,6 @@
<!-- Élément sélectionné (modèles LauncherEditor / GameEditor ci-dessous) -->
<ContentControl Grid.Column="1" Margin="18,0,0,0" Content="{Binding Selected}" Focusable="False" />
</Grid>
<!-- Publication -->
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,18,0,14" />
<DockPanel Margin="0,0,0,8">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}"
ToolTip="Afficher le catalogue actuellement publié" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Publier"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding PublishCommand}"
ToolTip="Contrôle les valeurs puis publie le catalogue (requête signée par votre clé)" />
</StackPanel>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding RevisionText}" Style="{StaticResource AdminHintStyle}" TextWrapping="Wrap" />
<TextBlock Text="{Binding PublicUrl}" Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0"
TextTrimming="CharacterEllipsis" ToolTip="Adresse publique lue par les launchers" />
</StackPanel>
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
@@ -598,7 +640,9 @@
<TextBlock Text="Nom du launcher" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding LauncherName, UpdateSourceTrigger=PropertyChanged}" MaxLength="80" />
<TextBlock Text="{Binding DefaultNameHint}" Style="{StaticResource AdminHintStyle}" Margin="2,4,0,14" TextWrapping="Wrap" />
<TextBlock Text="{Binding DefaultNameHint}" Style="{StaticResource AdminHintStyle}" Margin="2,4,0,0" TextWrapping="Wrap" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,2,0,14" TextWrapping="Wrap"
Text="Affiché dans les paramètres du launcher (roue crantée), l'info-bulle de l'icône près de l'horloge et, avec plusieurs jeux, le titre de la fenêtre (barre des tâches). Avec un seul jeu, l'en-tête montre le nom du jeu. Pris en compte au prochain démarrage du launcher, après publication." />
<TextBlock Text="Jeu affiché au premier lancement" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding GameChoices}"
@@ -621,9 +665,13 @@
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Kinds}"
SelectedItem="{Binding Kind, Mode=TwoWay}" DisplayMemberPath="Label" ToolTip="Type de lien (icône affichée)" />
<TextBox Grid.Column="1" Margin="8,0,0,0" Style="{StaticResource ModernTextBoxStyle}" MaxLength="40"
Text="{Binding Label, UpdateSourceTrigger=PropertyChanged}" ToolTip="Texte du bouton (vide : nom du type)" />
ui:TextAssist.Placeholder="{Binding Kind.Label}"
Text="{Binding Label, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Texte du bouton affiché aux joueurs (40 caractères au plus ; vide : nom du type, ex. « Discord »)" />
<TextBox Grid.Column="2" Margin="8,0,0,0" Style="{StaticResource ModernTextBoxStyle}" MaxLength="500"
Text="{Binding Url, UpdateSourceTrigger=PropertyChanged}" ToolTip="Adresse https://…" />
ui:TextAssist.Placeholder="https://…"
Text="{Binding Url, UpdateSourceTrigger=PropertyChanged}"
ToolTip="Adresse ouverte au clic sur le bouton (https:// obligatoire)" />
<Button Grid.Column="3" Margin="6,0,0,0" Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" ToolTip="Retirer ce lien" Command="{Binding RemoveCommand}" />
</Grid>
@@ -671,7 +719,22 @@
Margin="0,3,0,0" TextWrapping="Wrap" />
</StackPanel>
</DockPanel>
<!-- En-têtes des colonnes (mêmes largeurs que le modèle LinkEditor) -->
<Grid Margin="0,0,0,6" Visibility="{Binding HasLinks, Converter={StaticResource BoolToVisibility}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="130" />
<ColumnDefinition Width="130" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="36" />
</Grid.ColumnDefinitions>
<TextBlock Text="Type (icône)" Style="{StaticResource AdminFieldLabelStyle}" Margin="2,0,0,0" />
<TextBlock Grid.Column="1" Text="Texte du bouton" Style="{StaticResource AdminFieldLabelStyle}" Margin="10,0,0,0" />
<TextBlock Grid.Column="2" Text="Adresse du lien (https://…)" Style="{StaticResource AdminFieldLabelStyle}" Margin="10,0,0,0" />
</Grid>
<ItemsControl ItemsSource="{Binding Links}" Focusable="False" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,2,0,0" TextWrapping="Wrap"
Visibility="{Binding HasLinks, Converter={StaticResource BoolToVisibility}}"
Text="Texte du bouton vide : le nom du type est affiché (ex. « Discord »). L'adresse est ouverte dans le navigateur du joueur." />
</StackPanel>
</Border>
@@ -724,7 +787,8 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock x:Name="Number" Text="{Binding Version}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="Number" Text="{Binding Version}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Text="{Binding DateText}" FontSize="11.5" Margin="0,1,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
@@ -781,6 +845,68 @@
</Setter>
</Style>
<!-- ============================================================ Choix du jeu (pages qui ne concernent qu'un jeu à la fois) -->
<!-- Carte affichée tant qu'aucun jeu n'est choisi (propriétés GameTiles et GamePickerText de la page) : avec plusieurs
jeux, rien d'autre n'est affiché avant ce choix, pour ne jamais modifier ou publier le mauvais jeu par mégarde. -->
<DataTemplate x:Key="AdminGamePickerTemplate">
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<DockPanel>
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Margin="0,0,14,0">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.Gamepad}" />
</Border>
<StackPanel>
<TextBlock Text="Choisir le jeu" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="{Binding GamePickerText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
</StackPanel>
</DockPanel>
<ItemsControl ItemsSource="{Binding GameTiles}" Focusable="False" Margin="0,16,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Style="{StaticResource AdminGameTileStyle}" Command="{Binding SelectCommand}" ToolTip="{Binding Choice.Label}">
<DockPanel>
<Path DockPanel.Dock="Right" Width="10" Height="10" Stretch="Uniform" Margin="10,0,0,0" VerticalAlignment="Center"
Fill="{DynamicResource TextMutedBrush}" Data="{StaticResource Icon.ChevronRight}" />
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Width="34" Height="34" CornerRadius="10"
Margin="0,0,12,0" VerticalAlignment="Center">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Gamepad}" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="13.5" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Text="{Binding Detail}" FontSize="11.5" Margin="0,2,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
<!-- Jeu choisi (plusieurs jeux) : liste pour en changer, en haut de la page -->
<DataTemplate x:Key="AdminGameSwitcherTemplate">
<Border Style="{StaticResource AdminCardStyle}" Padding="16,10">
<DockPanel>
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Width="34" Height="34" CornerRadius="10"
Margin="0,0,12,0" VerticalAlignment="Center">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Gamepad}" />
</Border>
<TextBlock DockPanel.Dock="Left" Text="Jeu" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,12,0" VerticalAlignment="Center" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="360" HorizontalAlignment="Left"
ItemsSource="{Binding GameChoices}" SelectedItem="{Binding SelectedGame, Mode=TwoWay}" DisplayMemberPath="Label"
IsEnabled="{Binding IsGameSwitchEnabled}" ToolTip="Changer de jeu" />
</DockPanel>
</Border>
</DataTemplate>
<DataTemplate DataType="{x:Type achl:ChangelogPanelViewModel}">
<StackPanel>
<!-- Erreur (chargement, jeu non configuré sur le serveur, refus…) -->
@@ -814,13 +940,11 @@
</StackPanel>
</Border>
<!-- Jeu (plusieurs jeux déclarés dans launcher.json) -->
<DockPanel Margin="0,0,0,14" Visibility="{Binding HasSeveralGames, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Jeu" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,12,0" VerticalAlignment="Center" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="340" HorizontalAlignment="Left"
ItemsSource="{Binding GameChoices}" SelectedItem="{Binding SelectedGame, Mode=TwoWay}" DisplayMemberPath="Label"
IsEnabled="{Binding IsEditable}" />
</DockPanel>
<!-- Jeu : à choisir avant tout (plusieurs jeux), puis liste pour en changer -->
<ContentControl Content="{Binding}" ContentTemplate="{StaticResource AdminGamePickerTemplate}" Focusable="False"
Visibility="{Binding ShowGamePicker, Converter={StaticResource BoolToVisibility}}" />
<ContentControl Content="{Binding}" ContentTemplate="{StaticResource AdminGameSwitcherTemplate}" Focusable="False"
Visibility="{Binding ShowGameSwitcher, Converter={StaticResource BoolToVisibility}}" />
<!-- Aucune note dans le manifest publié -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding ShowNoVersions, Converter={StaticResource BoolToVisibility}}">
@@ -840,30 +964,12 @@
<StackPanel>
<TextBlock Text="VERSIONS" Style="{StaticResource SectionTitleStyle}" Margin="2,0,0,8" />
<ListBox ItemsSource="{Binding Versions}" SelectedItem="{Binding SelectedVersion, Mode=TwoWay}"
ItemContainerStyle="{StaticResource AdminVersionItemStyle}"
Background="Transparent" BorderThickness="0" Padding="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Disabled" />
Style="{StaticResource AdminListBoxStyle}"
ItemContainerStyle="{StaticResource AdminVersionItemStyle}" />
</StackPanel>
<!-- Notes de la version sélectionnée (modèle VersionEditor ci-dessous) -->
<ContentControl Grid.Column="1" Margin="18,0,0,0" Content="{Binding SelectedVersion}" Focusable="False" />
</Grid>
<!-- Enregistrement -->
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,18,0,14" />
<DockPanel Margin="0,0,0,8">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}"
ToolTip="Afficher les notes du manifest publié" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Enregistrer"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding SaveCommand}"
ToolTip="Contrôle les notes puis les enregistre dans le manifest publié (requête signée par votre clé)" />
</StackPanel>
<TextBlock Text="{Binding ManifestText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
@@ -1057,16 +1163,14 @@
</DockPanel>
</Border>
<!-- Jeu (plusieurs jeux déclarés dans launcher.json) -->
<DockPanel Margin="0,0,0,14" Visibility="{Binding HasSeveralGames, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Jeu" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,12,0" VerticalAlignment="Center" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="340" HorizontalAlignment="Left"
ItemsSource="{Binding GameChoices}" SelectedItem="{Binding SelectedGame, Mode=TwoWay}" DisplayMemberPath="Label"
IsEnabled="{Binding CanEdit}" />
</DockPanel>
<!-- Jeu : à choisir avant tout (plusieurs jeux), puis liste pour en changer -->
<ContentControl Content="{Binding}" ContentTemplate="{StaticResource AdminGamePickerTemplate}" Focusable="False"
Visibility="{Binding ShowGamePicker, Converter={StaticResource BoolToVisibility}}" />
<ContentControl Content="{Binding}" ContentTemplate="{StaticResource AdminGameSwitcherTemplate}" Focusable="False"
Visibility="{Binding ShowGameSwitcher, Converter={StaticResource BoolToVisibility}}" />
<!-- Dossier de publication -->
<Border Style="{StaticResource AdminCardStyle}">
<!-- Dossier de publication (une fois le jeu choisi) -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding HasSelectedGame, Converter={StaticResource BoolToVisibility}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
@@ -1160,34 +1264,6 @@
</ItemsControl>
</StackPanel>
</Border>
<!-- Avancement de l'envoi -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding ProgressText, Converter={StaticResource StringToVisibility}}">
<StackPanel>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Interrompre" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Pause}" Command="{Binding CancelCommand}"
ToolTip="Arrête l'envoi ; « Publier » le reprendra là où il s'est arrêté"
Visibility="{Binding IsPublishing, Converter={StaticResource BoolToVisibility}}" />
<TextBlock DockPanel.Dock="Right" Text="{Binding SpeedText}" FontSize="12" Margin="12,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="{Binding ProgressText}" FontSize="12.5" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Margin="0,12,0,0" Maximum="1" Value="{Binding ProgressValue, Mode=OneWay}" />
</StackPanel>
</Border>
<!-- Publication -->
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,8,0,14" />
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource AccentButtonStyle}" Content="Publier"
ui:IconAssist.Icon="{StaticResource Icon.CloudUpload}" Command="{Binding PublishCommand}"
ToolTip="Envoie les fichiers absents du serveur puis met la version en ligne (requêtes signées par votre clé)" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" Margin="0,0,16,0"
Text="L'envoi peut être interrompu et repris. Le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding ShowBusyBar, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
@@ -1368,15 +1444,6 @@
<ItemsControl ItemsSource="{Binding Invitations}" Focusable="False" IsEnabled="{Binding CanEdit}" />
</StackPanel>
</Border>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
Text="Nouveau PC : « Ajouter un appareil », code saisi sur le nouveau PC (« J'ai une invitation »), puis approbation ici avec l'empreinte qu'il affiche." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
@@ -1517,15 +1584,6 @@
</TextBlock>
</DockPanel>
<ItemsControl ItemsSource="{Binding Admins}" Focusable="False" IsEnabled="{Binding CanEdit}" />
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
Text="Toutes ces actions sont signées par votre clé et inscrites au journal d'audit. Il reste toujours au moins un propriétaire actif." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
@@ -1612,19 +1670,42 @@
SelectedItem="{Binding Result, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<Button Grid.Column="3" Style="{StaticResource IconButtonStyle}" Width="34" Height="34" Padding="9" Margin="8,0,0,0"
VerticalAlignment="Bottom" ui:IconAssist.Icon="{StaticResource Icon.Close}" ToolTip="Effacer les filtres"
VerticalAlignment="Bottom" ui:IconAssist.Icon="{StaticResource Icon.Close}" ToolTip="Effacer les filtres (dates comprises)"
Command="{Binding ClearFiltersCommand}" />
</Grid>
<DockPanel Margin="0,12,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
<Button Style="{StaticResource SmallButtonStyle}" Content="Exporter…" Margin="0,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding ExportCommand}"
ToolTip="Enregistre les entrées filtrées dans un fichier CSV (Excel)" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Actualiser"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<!-- Période : appliquée à la liste ET à l'export -->
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="140" />
<ColumnDefinition Width="140" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Période" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Periods}"
SelectedItem="{Binding Period, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<TextBlock Text="{Binding SummaryText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
<StackPanel Grid.Column="1" Margin="10,0,0,0">
<TextBlock Text="Du" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="10" ui:TextAssist.Placeholder="jj/mm/aaaa"
ToolTip="Premier jour inclus (vide : depuis le début du journal)"
Text="{Binding FromText, UpdateSourceTrigger=PropertyChanged, Delay=600}" />
</StackPanel>
<StackPanel Grid.Column="2" Margin="10,0,0,0">
<TextBlock Text="Au" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="10" ui:TextAssist.Placeholder="jj/mm/aaaa"
ToolTip="Dernier jour inclus (vide : jusqu'à maintenant)"
Text="{Binding ToText, UpdateSourceTrigger=PropertyChanged, Delay=600}" />
</StackPanel>
<StackPanel Grid.Column="3" Margin="12,0,0,0" VerticalAlignment="Bottom" Orientation="Horizontal">
<Path Width="14" Height="14" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Center" Visibility="{Binding HasDateError, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding DateError}" FontSize="12" Margin="6,0,0,0" TextWrapping="Wrap" VerticalAlignment="Center"
Foreground="{DynamicResource DangerBrush}" MaxWidth="260" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
@@ -1643,8 +1724,6 @@
Visibility="{Binding CanLoadMore, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding LastRefresh}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
@@ -1797,8 +1876,340 @@
<ItemsControl ItemsSource="{Binding Contents}" Focusable="False" />
</StackPanel>
</Border>
</StackPanel>
</DataTemplate>
<DockPanel Margin="0,0,0,8">
<!-- ============================================================ Page « Fichiers du serveur » -->
<!-- Élément du fil d'Ariane (www › exovivalzombie › objects) -->
<DataTemplate DataType="{x:Type afil:FolderCrumb}">
<StackPanel Orientation="Horizontal">
<Button x:Name="Crumb" Style="{StaticResource LinkButtonStyle}" Content="{Binding Label}" Command="{Binding OpenCommand}"
FontSize="13" VerticalAlignment="Center" ToolTip="Ouvrir ce dossier" />
<TextBlock x:Name="Current" Text="{Binding Label}" FontSize="13" FontWeight="SemiBold" VerticalAlignment="Center"
Foreground="{DynamicResource TextPrimaryBrush}" Visibility="Collapsed" />
<Path x:Name="Chevron" Width="10" Height="10" Stretch="Uniform" Margin="8,0" VerticalAlignment="Center"
Fill="{DynamicResource TextMutedBrush}" Data="{StaticResource Icon.ChevronRight}" />
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsCurrent}" Value="True">
<Setter TargetName="Crumb" Property="Visibility" Value="Collapsed" />
<Setter TargetName="Current" Property="Visibility" Value="Visible" />
<Setter TargetName="Chevron" Property="Visibility" Value="Collapsed" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- Fichier ou dossier : icône, nom, taille et date, état (pastille), action -->
<DataTemplate DataType="{x:Type afil:ServerFileItem}">
<Border x:Name="Row" CornerRadius="12" Padding="12,8" Margin="0,0,0,4" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}" ToolTip="{Binding ToolTipText}">
<Border.InputBindings>
<MouseBinding MouseAction="LeftDoubleClick" Command="{Binding OpenCommand}" />
</Border.InputBindings>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="30" Height="30" CornerRadius="9" Background="{DynamicResource BadgeBrush}">
<Path x:Name="Icon" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource TextSecondaryBrush}"
Data="{Binding IconKey, Converter={StaticResource IconKey}}" />
</Border>
<StackPanel Grid.Column="1" Margin="11,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock FontSize="11.5" Margin="0,1,0,0" TextTrimming="CharacterEllipsis" Foreground="{DynamicResource TextMutedBrush}">
<Run Text="{Binding SizeText, Mode=OneWay}" />
<Run Text=" · " />
<Run Text="{Binding DateText, Mode=OneWay}" />
</TextBlock>
</StackPanel>
<!-- État : Utilisé / Protégé / Inutilisé / Non vérifiable / Lien -->
<Border x:Name="Chip" Grid.Column="2" CornerRadius="9" Padding="9,2" Margin="10,0,0,0" VerticalAlignment="Center"
Background="{DynamicResource ChipBrush}" BorderThickness="1" BorderBrush="{DynamicResource BorderStrongBrush}"
Visibility="{Binding StatusText, Converter={StaticResource StringToVisibility}}">
<TextBlock x:Name="ChipText" Text="{Binding StatusText}" FontSize="11" FontWeight="SemiBold"
Foreground="{DynamicResource TextSecondaryBrush}" />
</Border>
<StackPanel Grid.Column="3" Orientation="Horizontal" Margin="10,0,0,0" VerticalAlignment="Center">
<Button x:Name="OpenButton" Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.ChevronRight}" ToolTip="Ouvrir le dossier"
Command="{Binding OpenCommand}" Visibility="Collapsed" />
<Button x:Name="DeleteButton" Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" ToolTip="Supprimer ce fichier (le serveur revérifie les manifests)"
Command="{Binding DeleteCommand}" Visibility="Collapsed" />
</StackPanel>
</Grid>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsDirectory}" Value="True">
<Setter TargetName="OpenButton" Property="Visibility" Value="Visible" />
<Setter TargetName="Row" Property="Cursor" Value="Hand" />
<Setter TargetName="Icon" Property="Fill" Value="{DynamicResource Accent2Brush}" />
</DataTrigger>
<DataTrigger Binding="{Binding CanDelete}" Value="True">
<Setter TargetName="DeleteButton" Property="Visibility" Value="Visible" />
<Setter TargetName="Chip" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="ChipText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="in_use">
<Setter TargetName="Chip" Property="BorderBrush" Value="{DynamicResource SuccessBrush}" />
<Setter TargetName="ChipText" Property="Foreground" Value="{DynamicResource SuccessBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="protected">
<Setter TargetName="Chip" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="ChipText" Property="Foreground" Value="{DynamicResource Accent2Brush}" />
</DataTrigger>
<Trigger SourceName="Row" Property="IsMouseOver" Value="True">
<Setter TargetName="Row" Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
</Trigger>
</DataTemplate.Triggers>
</DataTemplate>
<DataTemplate DataType="{x:Type afil:FilesPanelViewModel}">
<StackPanel>
<!-- Erreur (refus du serveur : fichier utilisé, droits du dossier…) -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<!-- Résultat de la dernière suppression -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<!-- Espace disque -->
<Border Style="{StaticResource AdminCardStyle}" Padding="16,12" Visibility="{Binding DiskText, Converter={StaticResource StringToVisibility}}">
<DockPanel>
<Path Width="18" Height="18" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Harddisk}"
VerticalAlignment="Center" Margin="0,0,12,0" />
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding DiskText}" FontSize="12.5" />
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="4" Margin="0,8,0,0" Maximum="1"
Value="{Binding DiskUsedRatio, Mode=OneWay}" />
</StackPanel>
</DockPanel>
</Border>
<!-- Nettoyage du dossier « objects » d'un jeu -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding HasPruneGames, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<DockPanel>
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Margin="0,0,14,0">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.Broom}" />
</Border>
<StackPanel>
<TextBlock Text="Nettoyer le dossier « objects » d'un jeu" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" TextWrapping="Wrap"
Text="Supprime les fichiers que la version en ligne n'utilise plus (versions précédentes). Le manifest en ligne est relu au moment du nettoyage ; rien n'est fait pendant une publication." />
</StackPanel>
</DockPanel>
<DockPanel Margin="0,14,0,0">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Analyser" Margin="10,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding AnalyzeCommand}"
ToolTip="Compte les fichiers inutilisés sans rien supprimer" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding PruneGames}"
SelectedItem="{Binding PruneGame, Mode=TwoWay}" DisplayMemberPath="Label"
ui:TextAssist.Placeholder="Choisir le jeu à nettoyer…" />
</DockPanel>
<StackPanel Margin="0,12,0,0" Visibility="{Binding HasPrune, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding PruneText}" FontSize="12.5" TextWrapping="Wrap" />
<TextBlock Text="{Binding PruneDetail}" Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0" TextWrapping="Wrap"
Visibility="{Binding PruneDetail, Converter={StaticResource StringToVisibility}}" />
<Button Style="{StaticResource DangerButtonStyle}" Content="{Binding PruneButtonText}" HorizontalAlignment="Left"
Margin="0,12,0,0" ui:IconAssist.Icon="{StaticResource Icon.Broom}" Command="{Binding PruneCommand}"
Visibility="{Binding CanPrune, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Dossier affiché : fil d'Ariane, remonter -->
<DockPanel Margin="2,4,0,10">
<Button DockPanel.Dock="Left" Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" ToolTip="Dossier parent" Command="{Binding UpCommand}" />
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{StaticResource Icon.FolderOpen}" VerticalAlignment="Center" Margin="0,0,10,0" />
<ItemsControl ItemsSource="{Binding Crumbs}" Focusable="False" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</DockPanel>
<!-- Contenu -->
<TextBlock Text="{Binding EmptyText}" Style="{StaticResource AdminHintStyle}" FontSize="12.5" Margin="4,4,0,16"
Visibility="{Binding EmptyText, Converter={StaticResource StringToVisibility}}" />
<ItemsControl ItemsSource="{Binding Entries}" Focusable="False" />
</StackPanel>
</DataTemplate>
<!-- ============================================================ Barres d'actions des pages (toujours visibles) -->
<!--
Affichées par la fenêtre d'administration SOUS la zone qui défile (Views/Admin/AdminWindow.xaml) : les boutons
« Recharger », « Publier »… restent accessibles quelle que soit la position dans la page. Modèle choisi par
AdminFooterTemplateSelector : « AdminFooter.<type de la page> », sinon « AdminFooter.None » (rien).
-->
<ui:AdminFooterTemplateSelector x:Key="AdminFooterSelector" />
<DataTemplate x:Key="AdminFooter.None">
<Border />
</DataTemplate>
<Style x:Key="AdminFooterSeparatorStyle" TargetType="Border">
<Setter Property="Height" Value="1" />
<Setter Property="Margin" Value="0,10,0,12" />
<Setter Property="Background" Value="{DynamicResource BorderBrush}" />
</Style>
<DataTemplate x:Key="AdminFooter.DashboardPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Text="{Binding LastRefresh}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.GamesPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}"
ToolTip="Afficher le catalogue actuellement publié" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Publier"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding PublishCommand}"
ToolTip="Contrôle les valeurs puis publie le catalogue (requête signée par votre clé)" />
</StackPanel>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding RevisionText}" Style="{StaticResource AdminHintStyle}" TextWrapping="Wrap" />
<TextBlock Text="{Binding PublicUrl}" Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0"
TextTrimming="CharacterEllipsis" ToolTip="Adresse publique lue par les launchers" />
</StackPanel>
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.ChangelogPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}"
ToolTip="Afficher les notes du manifest publié" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Enregistrer"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding SaveCommand}"
ToolTip="Contrôle les notes puis les enregistre dans le manifest publié (requête signée par votre clé)" />
</StackPanel>
<TextBlock Text="{Binding ManifestText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.PublishPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<!-- Avancement de l'envoi -->
<Border Style="{StaticResource AdminCardStyle}" Padding="16,12" Margin="0,0,0,10"
Visibility="{Binding ProgressText, Converter={StaticResource StringToVisibility}}">
<StackPanel>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Interrompre" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Pause}" Command="{Binding CancelCommand}"
ToolTip="Arrête l'envoi ; « Publier » le reprendra là où il s'est arrêté"
Visibility="{Binding IsPublishing, Converter={StaticResource BoolToVisibility}}" />
<TextBlock DockPanel.Dock="Right" Text="{Binding SpeedText}" FontSize="12" Margin="12,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="{Binding ProgressText}" FontSize="12.5" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Margin="0,10,0,0" Maximum="1" Value="{Binding ProgressValue, Mode=OneWay}" />
</StackPanel>
</Border>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource AccentButtonStyle}" Content="Publier"
ui:IconAssist.Icon="{StaticResource Icon.CloudUpload}" Command="{Binding PublishCommand}"
ToolTip="Envoie les fichiers absents du serveur puis met la version en ligne (requêtes signées par votre clé)" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" Margin="0,0,16,0"
Text="L'envoi peut être interrompu et repris. Le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding ShowBusyBar, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.MyDevicesPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
Text="Nouveau PC : « Ajouter un appareil », code saisi sur le nouveau PC (« J'ai une invitation »), puis approbation ici avec l'empreinte qu'il affiche." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.AdminsPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
Text="Toutes ces actions sont signées par votre clé et inscrites au journal d'audit. Il reste toujours au moins un propriétaire actif." />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.AuditPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
<Button Style="{StaticResource SmallButtonStyle}" Content="Exporter…" Margin="0,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding ExportCommand}"
ToolTip="Enregistre les entrées filtrées (période comprise) dans un fichier CSV (Excel)" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Actualiser"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
</StackPanel>
<TextBlock Text="{Binding SummaryText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" TextWrapping="Wrap" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.SigningPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Actualiser" Margin="12,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
@@ -1809,4 +2220,22 @@
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.FilesPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding ShowOnlyUnused, Mode=TwoWay}" FontSize="12.5"
VerticalAlignment="Center" Margin="0,0,14,0" Content="Fichiers inutilisés seulement"
ToolTip="N'affiche que les fichiers supprimables (les dossiers restent affichés pour parcourir le site)" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Actualiser"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}" />
</StackPanel>
<TextBlock Text="{Binding SummaryText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" TextWrapping="Wrap" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
</ResourceDictionary>
+32 -5
View File
@@ -442,11 +442,21 @@
<ControlTemplate TargetType="TextBox">
<Border x:Name="Bd" CornerRadius="12" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
VerticalAlignment="Center" Focusable="False"
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" />
<Grid>
<!-- Texte indicatif (ui:TextAssist.Placeholder), visible tant que le champ est vide -->
<TextBlock x:Name="Placeholder" Margin="{TemplateBinding Padding}" Padding="2,0,0,0" VerticalAlignment="Center"
Text="{Binding Path=(ui:TextAssist.Placeholder), RelativeSource={RelativeSource TemplatedParent}}"
Foreground="{DynamicResource TextMutedBrush}" FontSize="{TemplateBinding FontSize}"
TextTrimming="CharacterEllipsis" IsHitTestVisible="False" Visibility="Collapsed" />
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
VerticalAlignment="Center" Focusable="False"
HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="Text" Value="">
<Setter TargetName="Placeholder" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
</Trigger>
@@ -473,10 +483,19 @@
<ControlTemplate TargetType="TextBox">
<Border x:Name="Bd" CornerRadius="12" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" Focusable="False"
HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" />
<Grid>
<TextBlock x:Name="Placeholder" Margin="{TemplateBinding Padding}" Padding="2,0,0,0" VerticalAlignment="Top"
Text="{Binding Path=(ui:TextAssist.Placeholder), RelativeSource={RelativeSource TemplatedParent}}"
Foreground="{DynamicResource TextMutedBrush}" FontSize="{TemplateBinding FontSize}"
TextWrapping="Wrap" IsHitTestVisible="False" Visibility="Collapsed" />
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" Focusable="False"
HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="Text" Value="">
<Setter TargetName="Placeholder" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
</Trigger>
@@ -556,6 +575,11 @@
<Grid>
<ToggleButton x:Name="Toggle" Style="{StaticResource ModernComboBoxToggleStyle}"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" />
<!-- Texte indicatif (ui:TextAssist.Placeholder), visible tant que rien n'est choisi -->
<TextBlock x:Name="Placeholder" Margin="14,0,34,0" VerticalAlignment="Center" IsHitTestVisible="False"
Text="{Binding Path=(ui:TextAssist.Placeholder), RelativeSource={RelativeSource TemplatedParent}}"
Foreground="{DynamicResource TextMutedBrush}" FontSize="{TemplateBinding FontSize}"
TextTrimming="CharacterEllipsis" Visibility="Collapsed" />
<ContentPresenter Margin="12,0,34,0" VerticalAlignment="Center" HorizontalAlignment="Left" IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
@@ -578,6 +602,9 @@
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="SelectedItem" Value="{x:Null}">
<Setter TargetName="Placeholder" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45" />
</Trigger>
+2
View File
@@ -92,5 +92,7 @@
<Geometry x:Key="Icon.PackageVariant">M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z</Geometry>
<Geometry x:Key="Icon.Laptop">M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z</Geometry>
<Geometry x:Key="Icon.AccountRemove">M15,14C17.67,14 23,15.33 23,18V20H7V18C7,15.33 12.33,14 15,14M15,12A4,4 0 0,1 11,8A4,4 0 0,1 15,4A4,4 0 0,1 19,8A4,4 0 0,1 15,12M5,9.59L7.12,7.46L8.54,8.88L6.41,11L8.54,13.12L7.12,14.54L5,12.41L2.88,14.54L1.46,13.12L3.59,11L1.46,8.88L2.88,7.46L5,9.59Z</Geometry>
<Geometry x:Key="Icon.Broom">M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z</Geometry>
<Geometry x:Key="Icon.CalendarRange">M9,10H7V12H9V10M13,10H11V12H13V10M17,10H15V12H17V10M19,3H18V1H16V3H8V1H6V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M19,19H5V8H19V19Z</Geometry>
</ResourceDictionary>
+14 -2
View File
@@ -887,12 +887,22 @@ public sealed class GameViewModel : ObservableObject, IDisposable
if (_shortcuts.RetargetDesktopShortcut(shortcut)) repaired++;
}
return repaired switch
// Nom affiché du jeu modifié depuis le menu d'administration : les raccourcis nommés par le launcher le suivent.
var renamed = _shortcuts.RenameDesktopShortcuts();
var moved = repaired switch
{
0 => null,
1 => "Le raccourci du Bureau a été mis à jour vers l'emplacement actuel du launcher.",
_ => $"{repaired} raccourcis du Bureau ont été mis à jour vers l'emplacement actuel du launcher."
};
var named = renamed switch
{
0 => null,
1 => $"Le raccourci du Bureau a été renommé « {_shortcuts.ShortcutName} ».",
_ => $"{renamed} raccourcis du Bureau ont été renommés « {_shortcuts.ShortcutName} »."
};
return moved is null ? named : named is null ? moved : $"{moved} {named}";
}
catch (Exception ex)
{
@@ -1431,10 +1441,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
var existing = _shortcuts.FindDesktopShortcuts();
if (existing.Count > 0)
{
// Redirigés vers ce launcher, icône du jeu, et nom affiché du jeu s'ils portent un nom posé par le launcher.
var updated = existing.Count(_shortcuts.RetargetDesktopShortcut);
var name = existing.Count == 1 ? _shortcuts.FindDesktopShortcuts().FirstOrDefault()?.Name ?? existing[0].Name : null;
return updated == existing.Count
? new ShortcutActionResult(true, existing.Count == 1
? $"Le raccourci « {existing[0].Name} » existait déjà : il a été mis à jour."
? $"Le raccourci « {name} » existait déjà : il a été mis à jour."
: $"{existing.Count} raccourcis existaient déjà : ils ont été mis à jour.")
: new ShortcutActionResult(false, "La mise à jour du raccourci a échoué. Consultez le dossier des journaux.");
}
+13 -5
View File
@@ -12,11 +12,12 @@
Background="Transparent"
ResizeMode="CanMinimize"
WindowStartupLocation="CenterOwner"
Icon="/Assets/launcher.ico"
Icon="pack://application:,,,/GameLauncher;component/Assets/launcher.ico"
FontFamily="{DynamicResource UiFont}"
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal">
TextOptions.TextFormattingMode="Ideal"
ui:WheelAssist.IsEnabled="True">
<!--
Menu d'administration (accès discret : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo).
@@ -83,9 +84,8 @@
<TextBlock DockPanel.Dock="Top" Text="ADMINISTRATION" Style="{StaticResource SectionTitleStyle}" Margin="6,0,0,10" />
<ListBox ItemsSource="{Binding PanelItems}" SelectedItem="{Binding SelectedPanel, Mode=TwoWay}"
Style="{StaticResource AdminListBoxStyle}"
ItemContainerStyle="{StaticResource AdminNavItemStyle}"
Background="Transparent" BorderThickness="0" Padding="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto" />
</DockPanel>
</Border>
@@ -97,7 +97,15 @@
<TextBlock Text="{Binding PanelDescription}" FontSize="12.5" Margin="0,4,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0">
<!-- Barre d'actions de la page (« Recharger », « Publier »…) : toujours visible, sous la zone qui défile
(modèle « AdminFooter.<type de la page> » de Themes/Admin.xaml) -->
<ContentControl DockPanel.Dock="Bottom" Margin="0,0,14,0" Focusable="False"
Content="{Binding SelectedPanel.ViewModel}"
ContentTemplateSelector="{StaticResource AdminFooterSelector}" />
<!-- Retour en haut : page changée, ou demandé par la page (après « Recharger », « Publier »…) -->
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0"
ui:ScrollAssist.ResetOn="{Binding SelectedPanel}"
ui:ScrollAssist.ScrollToTopToken="{Binding SelectedPanel.ViewModel.ScrollToTopToken}">
<!-- Modèle choisi selon le type de la page (Themes/Admin.xaml) -->
<ContentControl Content="{Binding SelectedPanel.ViewModel}" Focusable="False" />
</ScrollViewer>
+1 -2
View File
@@ -41,9 +41,8 @@
</DockPanel>
<ListBox ItemsSource="{Binding Keys}" SelectedItem="{Binding SelectedKey, Mode=TwoWay}"
Style="{StaticResource AdminListBoxStyle}"
ItemContainerStyle="{StaticResource AdminKeyItemStyle}" MaxHeight="216"
Background="Transparent" BorderThickness="0" Padding="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto"
Visibility="{Binding HasKeys, Converter={StaticResource BoolToVisibility}}" />
+1 -1
View File
@@ -10,7 +10,7 @@
Background="Transparent"
ResizeMode="CanMinimize"
WindowStartupLocation="CenterScreen"
Icon="/Assets/launcher.ico"
Icon="pack://application:,,,/GameLauncher;component/Assets/launcher.ico"
FontFamily="{DynamicResource UiFont}"
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
@@ -377,4 +377,59 @@ public class AdminTpmLoginScreenTests
Assert.Equal(TimeSpan.Zero, new TpmLockoutInfo(5, 32, TimeSpan.FromMinutes(10), false).MaxWait);
Assert.False(new TpmLockoutInfo(40, 32, TimeSpan.Zero, true).HealsWithTime);
}
/// <summary>Réponse TPM2_GetCapability(TPM_CAP_TPM_PROPERTIES) avec les propriétés données.</summary>
private static byte[] CapabilityResponse(uint responseCode, params (uint Property, uint Value)[] properties)
{
var data = new byte[19 + properties.Length * 8];
System.Buffers.Binary.BinaryPrimitives.WriteUInt16BigEndian(data, 0x8001);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(2), (uint)data.Length);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(6), responseCode);
data[10] = 0; // moreData
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(11), 6);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(15), (uint)properties.Length);
for (var i = 0; i < properties.Length; i++)
{
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(19 + i * 8), properties[i].Property);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(data.AsSpan(23 + i * 8), properties[i].Value);
}
return data;
}
[Fact]
public void Chip_managed_by_windows_is_not_reported_locked()
{
// PC ordinaire : Windows a défini l'autorisation « lockout » (lockoutAuthSet, bit 2) et ownerAuthSet (bit 0),
// aucune erreur de code. Ce n'est PAS un blocage (bug : la puce était vue comme toujours bloquée).
var info = TpmLockoutInfo.FromCapabilityResponse(CapabilityResponse(0,
(0x200, (1u << 0) | (1u << 1) | (1u << 2)), (0x20E, 0), (0x20F, 32), (0x210, 7200), (0x211, 86400)));
Assert.NotNull(info);
Assert.False(info!.IsLockedOut);
Assert.Equal(32, info.RemainingTries);
Assert.Equal(TimeSpan.FromHours(2), info.RecoveryInterval);
}
[Fact]
public void Chip_lockout_is_read_from_the_in_lockout_bit_or_the_counter()
{
var flagged = TpmLockoutInfo.FromCapabilityResponse(CapabilityResponse(0,
(0x200, (1u << 2) | (1u << 9)), (0x20E, 3), (0x20F, 32), (0x210, 600)));
Assert.True(flagged!.IsLockedOut);
var counted = TpmLockoutInfo.FromCapabilityResponse(CapabilityResponse(0,
(0x200, 1u << 2), (0x20E, 32), (0x20F, 32), (0x210, 600)));
Assert.True(counted!.IsLockedOut);
Assert.Equal(TimeSpan.FromMinutes(10), counted.MaxWait);
}
[Fact]
public void Unreadable_chip_answers_give_no_lockout_state()
{
Assert.Null(TpmLockoutInfo.FromCapabilityResponse(CapabilityResponse(0x9A2))); // erreur TPM
Assert.Null(TpmLockoutInfo.FromCapabilityResponse(CapabilityResponse(0, (0x200, 0), (0x20E, 0)))); // incomplète
Assert.Null(TpmLockoutInfo.FromCapabilityResponse(new byte[12])); // tronquée
var full = CapabilityResponse(0, (0x200, 0), (0x20E, 0), (0x20F, 32), (0x210, 600));
Assert.Null(TpmLockoutInfo.FromCapabilityResponse(full.AsSpan(0, full.Length - 4))); // dernière valeur coupée
}
}
@@ -6,6 +6,7 @@ using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Admin;
@@ -81,6 +82,95 @@ public class AuditPanelTests
Assert.StartsWith("Date;Action;Code;Résultat", lines[0]);
Assert.True(lines.Length - 1 > AuditPanelViewModel.MaxDisplayed, $"{lines.Length - 1} lignes exportées");
Assert.Contains(dialogs.Messages, m => m.StartsWith("Journal exporté"));
// Consultation et export inscrits au journal par le serveur.
var journal = api.Services.GetRequiredService<GameLauncher.Server.Storage.AuditLog>().Read(50, null).Entries;
Assert.Contains(journal, e => e.Action == "audit.view");
var export = journal.First(e => e.Action == "audit.export");
Assert.StartsWith($"{lines.Length - 1} entrée(s) ; toutes les dates", export.Details);
Assert.Equal("Journal d'audit exporté (CSV)", AuditTexts.Action(export.Action));
Assert.True(AuditTexts.InCategory("audit.export", "audit."));
Assert.False(AuditTexts.InCategory("audit.suppressed", "audit."));
}
/// <summary>Attend la fin des rechargements déclenchés par la période (un second peut suivre le premier).</summary>
private static async Task SettledAsync(AuditPanelViewModel page)
{
for (var i = 0; i < 50; i++)
{
var reload = page.PeriodReload;
await reload;
if (ReferenceEquals(reload, page.PeriodReload) && !page.IsBusy) return;
await Task.Delay(10);
}
}
[Fact]
public async Task The_journal_is_filtered_by_period_and_the_export_follows()
{
await using var api = await TestApi.StartAsync();
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
// Trois entrées datées de dix jours plus tôt (horloge du serveur reculée le temps de les écrire).
var audit = api.Services.GetRequiredService<GameLauncher.Server.Storage.AuditLog>();
api.Time.Advance(TimeSpan.FromDays(-10));
var oldDay = api.Time.GetUtcNow().ToLocalTime().Date;
for (var i = 0; i < 3; i++) audit.Write("test.old", AuditResults.Ok, details: $"ancienne {i}");
api.Time.Advance(TimeSpan.FromDays(10));
var today = api.Time.GetUtcNow().ToLocalTime().Date;
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(AuditPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<AuditPanelViewModel>(workspace.SelectedPanel!.ViewModel);
page.Today = () => today;
Assert.Contains(page.Entries, e => e.ActionCode == "test.old");
// « Aujourd'hui » : les entrées d'il y a dix jours disparaissent (rechargées depuis le serveur).
page.Period = page.Periods.First(p => p.Label == "Aujourd'hui");
await SettledAsync(page);
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal(today.ToString("dd/MM/yyyy"), page.FromText);
Assert.DoesNotContain(page.Entries, e => e.ActionCode == "test.old");
Assert.Contains(page.Entries, e => e.ActionText == "Connexion");
Assert.True(page.HasFilters);
// Dates choisies : le jour des anciennes entrées uniquement.
page.FromText = oldDay.ToString("dd/MM/yyyy");
page.ToText = oldDay.ToString("dd/MM/yyyy");
await SettledAsync(page);
Assert.Equal("custom", page.Period.Value);
Assert.Equal(3, page.Entries.Count);
Assert.All(page.Entries, e => Assert.Equal("test.old", e.ActionCode));
// L'export suit la période.
dialogs.NextSaveFile = folder.File("periode.csv");
await page.ExportCommand.ExecuteAsync();
Assert.Equal(1 + 3, File.ReadAllLines(dialogs.NextSaveFile).Length);
var announced = api.Services.GetRequiredService<GameLauncher.Server.Storage.AuditLog>().Read(50, null).Entries.First(e => e.Action == "audit.export");
Assert.Equal("3 entrée(s) ; du " + oldDay.ToString("dd/MM/yyyy", System.Globalization.CultureInfo.InvariantCulture) + " au " + oldDay.ToString("dd/MM/yyyy", System.Globalization.CultureInfo.InvariantCulture), announced.Details);
// Date invalide : signalée, la période précédente reste appliquée.
page.ToText = "32/13/2026";
Assert.True(page.HasDateError);
Assert.Equal(3, page.Entries.Count);
page.FromText = today.ToString("dd/MM/yyyy");
page.ToText = oldDay.ToString("dd/MM/yyyy");
Assert.Contains("postérieure", page.DateError);
// Effacer les filtres : toutes les dates.
page.ClearFiltersCommand.Execute(null);
await SettledAsync(page);
Assert.False(page.HasFilters);
Assert.False(page.HasDateError);
Assert.Contains(page.Entries, e => e.ActionCode == "test.old");
Assert.Contains(page.Entries, e => e.ActionText == "Connexion");
}
[Fact]
@@ -51,6 +51,7 @@ public class ChangelogPanelTests
Assert.Equal(new[] { "1.1", "1.0" }, page.Versions.Select(v => v.Version));
Assert.StartsWith("Version publiée : 1.1 · 2 versions", page.ManifestText);
Assert.False(page.HasSeveralGames);
Assert.False(page.ShowGamePicker); // un seul jeu déclaré : choisi d'office
Assert.False(page.HasChanges);
Assert.False(page.SaveCommand.CanExecute(null));
@@ -0,0 +1,134 @@
using System.Security.Cryptography;
using System.Text.Json;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Files;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Fichiers du serveur » du menu d'administration.</summary>
public class FilesPanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new FilesAdminModule() });
private static string Sha(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
[Fact]
public async Task Files_are_browsed_deleted_and_objects_pruned_from_the_page()
{
// Site : un jeu (manifest 1.1 : une archive, un objet), une ancienne archive et un objet orphelin.
var root = Path.Combine(Path.GetTempPath(), "launcher-files-panel-tests", Guid.NewGuid().ToString("N"), "www");
var game = Path.Combine(root, "exovivalzombie");
Directory.CreateDirectory(game);
var used = RandomNumberGenerator.GetBytes(500);
var orphan = RandomNumberGenerator.GetBytes(700);
foreach (var data in new[] { used, orphan })
{
var path = Path.Combine(game, ManifestCheck.ObjectPath(Sha(data)));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, data);
}
File.WriteAllBytes(Path.Combine(game, "EZR-1.1.zip"), new byte[100]);
File.WriteAllBytes(Path.Combine(game, "EZR-1.0.zip"), new byte[2048]);
var manifest = Path.Combine(game, "manifest.json");
File.WriteAllText(manifest, JsonSerializer.Serialize(new GameManifest
{
GameId = "EZR",
Version = "1.1",
Executable = "Game.exe",
Archive = new ArchiveInfo { Url = "EZR-1.1.zip", Size = 100, Sha256 = new string('a', 64) },
Files = new List<ManifestFile> { new() { Path = "Game.exe", Size = used.Length, Sha256 = Sha(used) } }
}, JsonDefaults.Options));
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Games:EZR:ManifestFile"] = manifest,
["LauncherApi:Files:Root"] = root
});
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(FilesPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<FilesPanelViewModel>(workspace.SelectedPanel!.ViewModel);
// Racine : un dossier ; espace disque ; jeu proposé au nettoyage.
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal("www", page.RootName);
var directory = Assert.Single(page.Entries);
Assert.True(directory.IsDirectory);
Assert.Contains("Espace libre", page.DiskText);
Assert.Equal("EZR", Assert.Single(page.PruneGames).Value);
Assert.False(page.UpCommand.CanExecute(null));
// Ouvrir le dossier du jeu : états des fichiers, fil d'Ariane.
await directory.OpenCommand.ExecuteAsync();
Assert.Equal("exovivalzombie", page.CurrentPath);
Assert.Equal(new[] { "www", "exovivalzombie" }, page.Crumbs.Select(c => c.Label));
var oldArchive = page.Entries.Single(e => e.Name == "EZR-1.0.zip");
Assert.True(oldArchive.CanDelete);
Assert.Equal("Inutilisé", oldArchive.StatusText);
var current = page.Entries.Single(e => e.Name == "EZR-1.1.zip");
Assert.False(current.CanDelete);
Assert.Equal("Utilisé", current.StatusText);
Assert.Contains("version 1.1", current.ToolTipText);
Assert.Equal("Protégé", page.Entries.Single(e => e.Name == "manifest.json").StatusText);
Assert.False(current.DeleteCommand.CanExecute(null));
Assert.Contains("inutilisé", page.SummaryText);
// Filtre : seulement les fichiers supprimables (et les dossiers).
page.ShowOnlyUnused = true;
Assert.Equal(new[] { "objects", "EZR-1.0.zip" }, page.Entries.Select(e => e.Name));
page.ShowOnlyUnused = false;
// Suppression annulée, puis confirmée.
dialogs.ConfirmAnswer = false;
await oldArchive.DeleteCommand.ExecuteAsync();
Assert.True(File.Exists(Path.Combine(game, "EZR-1.0.zip")));
dialogs.ConfirmAnswer = true;
await page.Entries.Single(e => e.Name == "EZR-1.0.zip").DeleteCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.False(File.Exists(Path.Combine(game, "EZR-1.0.zip")));
Assert.Contains("2 Ko libéré", page.StatusMessage);
Assert.DoesNotContain(page.Entries, e => e.Name == "EZR-1.0.zip");
// Nettoyage d'« objects » : analyse, puis suppression de l'orphelin.
await page.AnalyzeCommand.ExecuteAsync();
Assert.True(page.HasPrune);
Assert.True(page.CanPrune);
Assert.Contains("1 fichier(s)", page.PruneText);
await page.PruneCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("1 fichier(s) supprimé(s)", page.StatusMessage);
Assert.False(File.Exists(Path.Combine(game, ManifestCheck.ObjectPath(Sha(orphan)))));
Assert.True(File.Exists(Path.Combine(game, ManifestCheck.ObjectPath(Sha(used)))));
await page.AnalyzeCommand.ExecuteAsync();
Assert.False(page.CanPrune);
Assert.Contains("Rien à nettoyer", page.PruneText);
// Retour à la racine par le fil d'Ariane.
await page.Crumbs[0].OpenCommand.ExecuteAsync();
Assert.Equal("", page.CurrentPath);
Assert.Equal(ServerFileStatus.Directory, Assert.Single(page.Entries).Status);
}
[Theory]
[InlineData(0, "0 o")]
[InlineData(812, "812 o")]
[InlineData(2048, "2 Ko")]
[InlineData(1_400_000, "1,3 Mo")]
[InlineData(21_400_000_000, "19,9 Go")]
public void Sizes_are_readable(long bytes, string expected) => Assert.Equal(expected, FilesPanelViewModel.Bytes(bytes));
}
@@ -0,0 +1,172 @@
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Publishing;
using GameLauncher.Tests.Server;
namespace GameLauncher.Tests.Admin;
/// <summary>
/// Pages qui ne concernent qu'un jeu à la fois (« Notes de version », « Publier une version ») avec plusieurs jeux
/// déclarés : rien n'est chargé ni affiché avant le choix du jeu, et rien d'un jeu ne reste affiché pour un autre.
/// </summary>
public class MultiGamePanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new ChangelogAdminModule(), new PublishAdminModule() });
private sealed class Context : IAsyncDisposable
{
public required TestApi Api { get; init; }
public required TempFolder Folder { get; init; }
public required FakeDialogs Dialogs { get; init; }
public required AdminShellViewModel Shell { get; init; }
public required AdminWorkspaceViewModel Workspace { get; init; }
public required string EzrManifest { get; init; }
public required string OtherManifest { get; init; }
public async ValueTask DisposeAsync()
{
await Shell.DisposeAsync();
await Api.DisposeAsync();
Folder.Dispose();
}
public async Task<T> OpenAsync<T>(string panelId) where T : AdminPanelViewModel
{
Workspace.Navigate(panelId);
await Workspace.Activation;
return Assert.IsType<T>(Workspace.SelectedPanel!.ViewModel);
}
}
private static async Task<Context> StartAsync()
{
var ezr = ChangelogModuleTests.PublishedManifest("EZR");
var other = ChangelogModuleTests.PublishedManifest("ZR2");
var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Games:EZR:ManifestFile"] = ezr,
["LauncherApi:Games:ZR2:ManifestFile"] = other
});
var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
AdminTestKit.RegisterOwner(api, signer);
signer.Dispose();
var dialogs = new FakeDialogs();
var games = new[] { AdminTestKit.Game(), AdminTestKit.Game("ZR2", "Zombie Reborn 2") };
var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules, games: games));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
return new Context
{
Api = api, Folder = folder, Dialogs = dialogs, Shell = shell,
Workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen),
EzrManifest = ezr, OtherManifest = other
};
}
[Fact]
public async Task Release_notes_wait_for_the_game_to_be_chosen()
{
await using var context = await StartAsync();
var page = await context.OpenAsync<ChangelogPanelViewModel>(ChangelogPanelViewModel.PanelId);
// Aucun jeu par défaut : choix du jeu seulement, rien de chargé.
Assert.False(page.HasError, page.ErrorMessage);
Assert.Null(page.SelectedGame);
Assert.True(page.ShowGamePicker);
Assert.False(page.ShowGameSwitcher);
Assert.Empty(page.Versions);
Assert.False(page.ShowNoVersions);
Assert.Equal("", page.ManifestText);
Assert.False(page.ReloadCommand.CanExecute(null));
Assert.False(page.SaveCommand.CanExecute(null));
Assert.Equal(new[] { "Exovival Zombie Reborn", "Zombie Reborn 2" }, page.GameTiles.Select(t => t.Name));
Assert.Contains("ZR2", page.GameTiles[1].Detail);
// Choix par la tuile : notes du jeu choisi, liste pour changer de jeu.
page.GameTiles[1].SelectCommand.Execute(null);
await page.GameLoad;
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal("ZR2", page.SelectedGame?.Value);
Assert.False(page.ShowGamePicker);
Assert.True(page.ShowGameSwitcher);
Assert.Equal(new[] { "1.1", "1.0" }, page.Versions.Select(v => v.Version));
// Modification non enregistrée : changement de jeu refusé tant qu'elle n'est pas abandonnée.
page.SelectedVersion!.Title = "Notes de ZR2";
Assert.True(page.HasChanges);
var ezr = page.GameChoices.Single(c => c.Value == "EZR");
context.Dialogs.ConfirmAnswer = false;
page.SelectedGame = ezr;
Assert.Equal("ZR2", page.SelectedGame?.Value);
Assert.True(page.HasChanges);
context.Dialogs.ConfirmAnswer = true;
page.SelectedGame = ezr;
await page.GameLoad;
Assert.Equal("EZR", page.SelectedGame?.Value);
Assert.False(page.HasChanges);
Assert.Equal("Version 1.1", page.SelectedVersion!.Title);
// Enregistrement : seul le manifest du jeu choisi change.
var otherBefore = await File.ReadAllBytesAsync(context.OtherManifest);
page.SelectedVersion!.Title = "Notes d'EZR";
await page.SaveCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Notes d'EZR", await File.ReadAllTextAsync(context.EzrManifest));
Assert.Equal(otherBefore, await File.ReadAllBytesAsync(context.OtherManifest));
}
[Fact]
public async Task Publishing_waits_for_the_game_and_forgets_the_previous_one()
{
await using var context = await StartAsync();
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 2, bigFileBytes: 1024, archiveBytes: 2000);
var page = await context.OpenAsync<PublishPanelViewModel>(PublishPanelViewModel.PanelId);
// Aucun jeu par défaut : rien à choisir ni à publier avant le choix du jeu.
Assert.Null(page.SelectedGame);
Assert.True(page.ShowGamePicker);
Assert.False(page.HasSelectedGame);
Assert.Equal("", page.PublishedText);
Assert.False(page.ChooseCommand.CanExecute(null));
Assert.False(page.PublishCommand.CanExecute(null));
Assert.Equal(2, page.GameTiles.Count);
// Jeu choisi : version en ligne de ce jeu, dossier contrôlé pour ce jeu.
page.GameTiles[0].SelectCommand.Execute(null);
await page.GameLoad;
Assert.Equal("EZR", page.SelectedGame?.Value);
Assert.True(page.ShowGameSwitcher);
Assert.Equal("Version en ligne : 1.1", page.PublishedText);
context.Dialogs.NextOpenFile = release.ManifestPath;
await page.ChooseCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.HasRelease);
Assert.True(page.PublishCommand.CanExecute(null));
// Autre jeu : rien du premier ne reste (dossier, version à publier) ; le dossier d'EZR y est refusé.
page.SelectedGame = page.GameChoices.Single(c => c.Value == "ZR2");
await page.GameLoad;
Assert.Equal("Zombie Reborn 2 (ZR2)", page.SelectedGameLabel);
Assert.False(page.HasRelease);
Assert.Null(page.ManifestPath);
Assert.False(page.PublishCommand.CanExecute(null));
Assert.Equal("Version en ligne : 1.1", page.PublishedText);
await page.InspectAsync(release.ManifestPath);
Assert.True(page.HasError);
Assert.False(page.HasRelease);
// Retour à EZR, publication : la confirmation et le résultat nomment le jeu.
page.SelectedGame = page.GameChoices.Single(c => c.Value == "EZR");
await page.GameLoad;
Assert.False(page.HasError);
await page.InspectAsync(release.ManifestPath);
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Jeu : Exovival Zombie Reborn (EZR)", context.Dialogs.Confirmations.Last());
Assert.Contains("Version 1.2 de Exovival Zombie Reborn (EZR) publiée", page.StatusMessage);
}
}
@@ -35,6 +35,10 @@ public class PublishPanelTests
await workspace.Activation;
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
// Un seul jeu déclaré : choisi d'office, pas de choix du jeu affiché.
Assert.Equal("EZR", page.SelectedGame?.Value);
Assert.False(page.ShowGamePicker);
Assert.False(page.ShowGameSwitcher);
Assert.Equal("Version en ligne : 1.1", page.PublishedText);
Assert.False(page.PublishCommand.CanExecute(null));
@@ -55,7 +59,7 @@ public class PublishPanelTests
dialogs.ConfirmAnswer = true;
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Version 1.1 publiée", page.StatusMessage);
Assert.Contains("Version 1.1 de Exovival Zombie Reborn (EZR) publiée", page.StatusMessage);
Assert.False(page.IsPublishing);
Assert.False(page.HasInterrupted);
Assert.Equal("Nouveautés.", JsonNode.Parse(File.ReadAllText(manifest))!["changelog"]![0]!["changes"]![0]!.GetValue<string>());
@@ -105,7 +109,7 @@ public class PublishPanelTests
Assert.True(page.HasRelease, page.ErrorMessage);
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Version 1.2 publiée", page.StatusMessage);
Assert.Contains("Version 1.2 de Exovival Zombie Reborn (EZR) publiée", page.StatusMessage);
Assert.False(page.HasInterrupted);
});
@@ -0,0 +1,95 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Modules.Audit;
using GameLauncher.Server.Storage;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Server;
/// <summary>Consultations et exports du journal d'audit, eux-mêmes inscrits au journal.</summary>
public class AuditModuleTests
{
private static List<AuditEntry> Journal(TestApi api, string action) =>
api.Services.GetRequiredService<AuditLog>().Read(200, null).Entries.Where(e => e.Action == action).ToList();
[Fact]
public async Task Each_consultation_is_recorded_without_one_line_per_page()
{
await using var api = await TestApi.StartAsync();
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var audit = api.Services.GetRequiredService<AuditLog>();
for (var i = 0; i < 5; i++) audit.Write("test.entry", AuditResults.Ok, details: $"entrée {i}");
// Première page : inscrite APRÈS la lecture (absente de la page lue), avec l'auteur et la clé.
var first = await owner.GetAuditAsync(3);
Assert.DoesNotContain(first.Entries, e => e.Action == "audit.view");
var view = Assert.Single(Journal(api, "audit.view"));
Assert.Equal(AuditResults.Ok, view.Result);
Assert.Equal(owner.Profile.DisplayName, view.ActorName);
Assert.Equal(key.KeyId, view.KeyId);
Assert.Contains("3 entrée(s) lue(s)", view.Details);
// Pages suivantes (« Charger plus ») juste après : pas de nouvelle ligne.
var next = await owner.GetAuditAsync(3, first.NextCursor);
Assert.NotEmpty(next.Entries);
Assert.Single(Journal(api, "audit.view"));
// Nouvelle consultation (ouverture, « Actualiser », période) : toujours inscrite.
var limit = api.Time.GetUtcNow().AddMinutes(1);
await owner.GetAuditAsync(3, before: limit);
var views = Journal(api, "audit.view");
Assert.Equal(2, views.Count);
Assert.Contains("antérieures au", views[0].Details);
// Page suivante longtemps après : inscrite à nouveau.
api.Time.Advance(AuditConsultations.ContinuationWindow + TimeSpan.FromMinutes(1));
await owner.GetAuditAsync(3, first.NextCursor);
views = Journal(api, "audit.view");
Assert.Equal(3, views.Count);
Assert.Contains("entrées plus anciennes", views[0].Details);
}
[Fact]
public async Task Exports_are_recorded_and_checked()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
await owner.RecordAuditExportAsync(new AuditExportRequest
{
Count = 245,
From = new DateOnly(2026, 11, 1),
To = new DateOnly(2026, 11, 7),
Filters = "catégorie « Connexions »"
});
var export = Assert.Single(Journal(api, "audit.export"));
Assert.Equal(owner.Profile.DisplayName, export.ActorName);
Assert.Equal(ownerKey.KeyId, export.KeyId);
Assert.Equal("fichier CSV", export.Target);
Assert.Equal("245 entrée(s) ; du 01/11/2026 au 07/11/2026 ; filtres : catégorie « Connexions »", export.Details);
await owner.RecordAuditExportAsync(new AuditExportRequest { Count = 12 });
Assert.Equal("12 entrée(s) ; toutes les dates", Journal(api, "audit.export")[0].Details);
// Annonces invalides : refusées, rien d'inscrit.
foreach (var invalid in new[]
{
new AuditExportRequest { Count = -1 },
new AuditExportRequest { Count = 1, From = new DateOnly(2026, 11, 8), To = new DateOnly(2026, 11, 7) },
new AuditExportRequest { Count = 1, From = new DateOnly(1900, 1, 1) },
new AuditExportRequest { Count = 1, Filters = new string('x', AuditExportLimits.MaxFiltersLength + 1) }
})
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.RecordAuditExportAsync(invalid))).Status);
Assert.Equal(2, Journal(api, "audit.export").Count);
// Sans la permission de lire le journal : refusé.
var (editorKey, _) = await api.AddAdminAsync(owner, "Éditeur", AdminRoles.Editor);
await using var editor = await api.LoginAsync(editorKey);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.RecordAuditExportAsync(new AuditExportRequest { Count = 1 }))).Status);
Assert.Equal(2, Journal(api, "audit.export").Count);
}
}
@@ -0,0 +1,404 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Server;
/// <summary>Page « Fichiers du serveur » : consultation du site, suppression d'un fichier inutilisé, nettoyage d'« objects ».</summary>
public class FilesModuleTests
{
/// <summary>
/// Site de test :
/// www/exovivalzombie/manifest.json (version 1.1 : EZR-1.1.zip + 2 objets)
/// www/exovivalzombie/EZR-1.0.zip (ancienne archive : inutilisée)
/// www/exovivalzombie/objects/xx/…bin (2 objets utilisés + 1 orphelin + 1 fichier étranger)
/// www/catalog/catalog.json, keyring.json, images/icon.png (utilisée), images/old.png (inutilisée)
/// www/launcher/launcher-update.json → GameLauncher-2.0.0.exe (utilisé), GameLauncher-1.9.0.exe (inutilisé)
/// </summary>
private sealed class Site
{
public string Root { get; } = Path.Combine(Path.GetTempPath(), "launcher-files-tests", Guid.NewGuid().ToString("N"), "www");
public string Game => Path.Combine(Root, "exovivalzombie");
public string Manifest => Path.Combine(Game, "manifest.json");
public string Catalog => Path.Combine(Root, "catalog", "catalog.json");
public byte[] Exe { get; } = RandomNumberGenerator.GetBytes(3000);
public byte[] Data { get; } = RandomNumberGenerator.GetBytes(2000);
public byte[] Orphan { get; } = RandomNumberGenerator.GetBytes(4096);
public string ObjectFile(byte[] data) => Path.Combine(Game, ManifestCheck.ObjectPath(Sha(data)));
public static Site Create(string catalogHost)
{
var site = new Site();
Directory.CreateDirectory(site.Game);
foreach (var data in new[] { site.Exe, site.Data, site.Orphan })
{
Directory.CreateDirectory(Path.GetDirectoryName(site.ObjectFile(data))!);
File.WriteAllBytes(site.ObjectFile(data), data);
}
File.WriteAllText(Path.Combine(site.Game, "objects", "readme.txt"), "pas un objet");
File.WriteAllBytes(Path.Combine(site.Game, "EZR-1.1.zip"), new byte[1234]);
File.WriteAllBytes(Path.Combine(site.Game, "EZR-1.0.zip"), new byte[777]);
site.WriteManifest("1.1", "EZR-1.1.zip", site.Exe, site.Data);
var catalog = Path.GetDirectoryName(site.Catalog)!;
Directory.CreateDirectory(Path.Combine(catalog, "images"));
File.WriteAllBytes(Path.Combine(catalog, "images", "icon.png"), new byte[100]);
File.WriteAllBytes(Path.Combine(catalog, "images", "old.png"), new byte[50]);
File.WriteAllText(site.Catalog, JsonSerializer.Serialize(new
{
revision = 3,
games = new Dictionary<string, object> { ["EZR"] = new { icon = $"http://{catalogHost}/catalog/images/icon.png" } }
}));
File.WriteAllText(Path.Combine(catalog, "keyring.json"), "{}");
var launcher = Path.Combine(site.Root, "launcher");
Directory.CreateDirectory(launcher);
File.WriteAllBytes(Path.Combine(launcher, "GameLauncher-2.0.0.exe"), new byte[300]);
File.WriteAllBytes(Path.Combine(launcher, "GameLauncher-1.9.0.exe"), new byte[200]);
var payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { product = "x", version = "2.0.0", file = "GameLauncher-2.0.0.exe" }));
File.WriteAllText(Path.Combine(launcher, "launcher-update.json"),
JsonSerializer.Serialize(new { format = 1, payload = Convert.ToBase64String(payload), signature = "" }));
return site;
}
public void WriteManifest(string version, string archive, params byte[][] files)
{
var manifest = new GameManifest
{
GameId = "EZR",
Version = version,
Executable = "Game.exe",
Archive = new ArchiveInfo { Url = archive, Size = 1234, Sha256 = new string('a', 64) },
Files = files.Select((f, i) => new ManifestFile { Path = i == 0 ? "Game.exe" : $"Data/{i}.pak", Size = f.Length, Sha256 = Sha(f) }).ToList()
};
File.WriteAllText(Manifest, JsonSerializer.Serialize(manifest, JsonDefaults.Options));
}
public Dictionary<string, string?> Settings(bool explicitRoot = true)
{
var settings = new Dictionary<string, string?>
{
["LauncherApi:Games:EZR:ManifestFile"] = Manifest,
["LauncherApi:Catalog:PublicFile"] = Catalog
};
if (explicitRoot) settings["LauncherApi:Files:Root"] = Root;
return settings;
}
}
private static string Sha(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
private sealed class Context : IAsyncDisposable
{
public required TestApi Api { get; init; }
public required AdminSession Owner { get; init; }
public required Site Site { get; init; }
public async ValueTask DisposeAsync()
{
await Owner.DisposeAsync();
await Api.DisposeAsync();
}
}
private static async Task<Context> StartAsync(bool explicitRoot = true)
{
// L'hôte du catalogue (images) est celui de l'API de test (le port n'est connu qu'au démarrage : seul l'hôte compte).
var site = Site.Create("127.0.0.1");
var api = await TestApi.StartAsync(site.Settings(explicitRoot));
var key = api.AddOwner();
return new Context { Api = api, Owner = await api.LoginAsync(key), Site = site };
}
private static ServerFileEntry Entry(ServerFolder folder, string name) => folder.Entries.Single(e => e.Name == name);
[Fact]
public async Task The_site_is_listed_with_the_status_of_each_file()
{
await using var context = await StartAsync(explicitRoot: false); // racine par défaut : parent commun du jeu et du catalogue
var (owner, site) = (context.Owner, context.Site);
var root = await owner.GetServerFolderAsync("");
Assert.Equal("www", root.RootName);
Assert.Equal(new[] { "catalog", "exovivalzombie", "launcher" }, root.Entries.Select(e => e.Name));
Assert.All(root.Entries, e => Assert.Equal(ServerFileStatus.Directory, e.Status));
Assert.Equal(new[] { "EZR" }, root.PrunableGames);
Assert.Equal(1234 + 777 + site.Exe.Length + site.Data.Length + site.Orphan.Length + "pas un objet".Length +
new FileInfo(site.Manifest).Length, Entry(root, "exovivalzombie").Size);
Assert.True(root.FreeBytes > 0);
var game = await owner.GetServerFolderAsync("exovivalzombie");
Assert.Equal(ServerFileStatus.Protected, Entry(game, "manifest.json").Status);
Assert.Equal(ServerFileStatus.InUse, Entry(game, "EZR-1.1.zip").Status);
Assert.Contains("version 1.1", Entry(game, "EZR-1.1.zip").Reason);
Assert.Equal(ServerFileStatus.Unused, Entry(game, "EZR-1.0.zip").Status);
var prefix = Sha(site.Orphan)[..2];
var objects = await owner.GetServerFolderAsync($"exovivalzombie/objects/{prefix}");
Assert.Equal(ServerFileStatus.Unused, Entry(objects, Sha(site.Orphan) + ".bin").Status);
var used = await owner.GetServerFolderAsync($"exovivalzombie/objects/{Sha(site.Exe)[..2]}");
Assert.Equal(ServerFileStatus.InUse, Entry(used, Sha(site.Exe) + ".bin").Status);
var images = await owner.GetServerFolderAsync("catalog/images");
Assert.Equal(ServerFileStatus.InUse, Entry(images, "icon.png").Status);
Assert.Equal(ServerFileStatus.Unused, Entry(images, "old.png").Status);
var catalog = await owner.GetServerFolderAsync("catalog");
Assert.Equal(ServerFileStatus.Protected, Entry(catalog, "catalog.json").Status);
Assert.Equal(ServerFileStatus.Protected, Entry(catalog, "keyring.json").Status);
var launcher = await owner.GetServerFolderAsync("launcher");
Assert.Equal(ServerFileStatus.InUse, Entry(launcher, "GameLauncher-2.0.0.exe").Status);
Assert.Equal(ServerFileStatus.Unused, Entry(launcher, "GameLauncher-1.9.0.exe").Status);
Assert.Equal(ServerFileStatus.Protected, Entry(launcher, "launcher-update.json").Status);
}
[Fact]
public async Task Only_unused_files_can_be_deleted()
{
await using var context = await StartAsync();
var (api, owner, site) = (context.Api, context.Owner, context.Site);
var result = await owner.DeleteServerFileAsync("exovivalzombie/EZR-1.0.zip", 777);
Assert.Equal(777, result.FreedBytes);
Assert.False(File.Exists(Path.Combine(site.Game, "EZR-1.0.zip")));
var audit = (await owner.GetAuditAsync(5)).Entries.First();
Assert.Equal("files.delete", audit.Action);
Assert.Equal("exovivalzombie/EZR-1.0.zip", audit.Target);
// Référencés ou protégés : refusés, rien n'est supprimé.
async Task RefusedAsync(string path, long size, int status)
{
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync(path, size));
Assert.Equal(status, refused.Status);
}
await RefusedAsync("exovivalzombie/EZR-1.1.zip", 1234, 409);
await RefusedAsync("exovivalzombie/manifest.json", new FileInfo(site.Manifest).Length, 409);
await RefusedAsync($"exovivalzombie/objects/{Sha(site.Exe)[..2]}/{Sha(site.Exe)}.bin", site.Exe.Length, 409);
await RefusedAsync("catalog/images/icon.png", 100, 409);
await RefusedAsync("catalog/catalog.json", new FileInfo(site.Catalog).Length, 409);
await RefusedAsync("launcher/GameLauncher-2.0.0.exe", 300, 409);
Assert.True(File.Exists(Path.Combine(site.Game, "EZR-1.1.zip")));
Assert.True(File.Exists(site.Manifest));
Assert.True(File.Exists(site.ObjectFile(site.Exe)));
// Chemins refusés : en dehors du site, cachés, dossiers ; taille changée depuis l'affichage.
await RefusedAsync("../outside.txt", 1, 400);
await RefusedAsync("exovivalzombie/../../outside.txt", 1, 400);
await RefusedAsync("/etc/passwd", 1, 404); // lu comme « etc/passwd » DANS le site : introuvable
await RefusedAsync("exovivalzombie\\EZR-1.1.zip", 1234, 400);
await RefusedAsync("exovivalzombie/.publish-x/manifest.json", 1, 400);
await RefusedAsync("exovivalzombie/objects", 0, 400);
await RefusedAsync("catalog/images/old.png", 49, 409);
await RefusedAsync("catalog/images/absent.png", 1, 404);
Assert.True(File.Exists(Path.Combine(site.Root, "catalog", "images", "old.png")));
// L'ancienne image et l'ancien exécutable, eux, sont supprimés.
await owner.DeleteServerFileAsync("catalog/images/old.png", 50);
await owner.DeleteServerFileAsync("launcher/GameLauncher-1.9.0.exe", 200);
}
[Fact]
public async Task Nothing_is_deleted_next_to_an_unreadable_manifest_or_during_a_publication()
{
await using var context = await StartAsync();
var (api, owner, site) = (context.Api, context.Owner, context.Site);
// Publication en cours (dépôt « .publish-… ») : refus, même pour un fichier inutilisé.
var staging = Path.Combine(site.Game, ".publish-0123456789abcdef0123456789abcdef");
Directory.CreateDirectory(staging);
var during = await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync("exovivalzombie/EZR-1.0.zip", 777));
Assert.Equal(409, during.Status);
Assert.Equal(ApiErrorCodes.PublicationInProgress, during.Code);
Assert.DoesNotContain((await owner.GetServerFolderAsync("exovivalzombie")).Entries, e => e.Name.StartsWith('.'));
Directory.Delete(staging);
// Manifest illisible : impossible de vérifier, donc rien n'est supprimable dans le dossier du jeu.
File.WriteAllText(site.Manifest, "{ pas du json");
var game = await owner.GetServerFolderAsync("exovivalzombie");
Assert.Equal(ServerFileStatus.Unknown, Entry(game, "EZR-1.0.zip").Status);
var unknown = await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync("exovivalzombie/EZR-1.0.zip", 777));
Assert.Equal(409, unknown.Status);
Assert.True(File.Exists(Path.Combine(site.Game, "EZR-1.0.zip")));
}
[Fact]
public async Task Objects_no_longer_used_by_the_manifest_are_pruned()
{
await using var context = await StartAsync();
var (api, owner, site) = (context.Api, context.Owner, context.Site);
var preview = await owner.GetObjectsPruneAsync("EZR");
Assert.Equal("1.1", preview.Version);
Assert.Equal(2, preview.ReferencedCount);
Assert.Equal(0, preview.MissingCount);
Assert.Equal(1, preview.OrphanCount);
Assert.Equal(site.Orphan.Length, preview.OrphanBytes);
Assert.Equal(new[] { ManifestCheck.ObjectPath(Sha(site.Orphan)) }, preview.Examples);
Assert.False(preview.PublicationInProgress);
// Manifest changé depuis l'analyse : refus.
var stale = await Assert.ThrowsAsync<ApiException>(() => owner.PruneObjectsAsync("EZR", new string('0', 64)));
Assert.Equal(ApiErrorCodes.RevisionConflict, stale.Code);
Assert.True(File.Exists(site.ObjectFile(site.Orphan)));
// Publication en cours : refus.
var staging = Path.Combine(site.Game, ".publish-0123456789abcdef0123456789abcdef");
Directory.CreateDirectory(staging);
Assert.True((await owner.GetObjectsPruneAsync("EZR")).PublicationInProgress);
var during = await Assert.ThrowsAsync<ApiException>(() => owner.PruneObjectsAsync("EZR", preview.ManifestSha256));
Assert.Equal(ApiErrorCodes.PublicationInProgress, during.Code);
Directory.Delete(staging);
var result = await owner.PruneObjectsAsync("EZR", preview.ManifestSha256);
Assert.Equal(1, result.Deleted);
Assert.Equal(site.Orphan.Length, result.FreedBytes);
Assert.False(File.Exists(site.ObjectFile(site.Orphan)));
Assert.True(File.Exists(site.ObjectFile(site.Exe)));
Assert.True(File.Exists(site.ObjectFile(site.Data)));
Assert.True(File.Exists(Path.Combine(site.Game, "objects", "readme.txt"))); // pas un objet : jamais touché
Assert.Contains((await owner.GetAuditAsync(5)).Entries, e => e.Action == "files.prune" && e.Target == "EZR objects");
// Nouvelle version qui n'utilise plus l'exécutable : il devient orphelin.
site.WriteManifest("1.2", "EZR-1.1.zip", site.Data);
var after = await owner.GetObjectsPruneAsync("EZR");
Assert.Equal(1, after.OrphanCount);
await owner.PruneObjectsAsync("EZR", after.ManifestSha256);
Assert.False(File.Exists(site.ObjectFile(site.Exe)));
Assert.True(File.Exists(site.ObjectFile(site.Data)));
}
[Fact]
public async Task Files_used_by_any_manifest_of_the_site_are_kept()
{
await using var context = await StartAsync();
var (owner, site) = (context.Owner, context.Site);
// Un autre jeu (non configuré sur le serveur) : archive dans un dossier voisin (« ../ »), fichiers servis depuis le
// dossier « objects » du premier jeu par une adresse complète de ce serveur.
var other = Path.Combine(site.Root, "autrejeu");
var shared = Path.Combine(site.Root, "partage");
Directory.CreateDirectory(other);
Directory.CreateDirectory(shared);
File.WriteAllBytes(Path.Combine(shared, "commun.zip"), new byte[10]);
File.WriteAllBytes(Path.Combine(shared, "libre.zip"), new byte[11]);
File.WriteAllText(Path.Combine(other, "manifest.json"), JsonSerializer.Serialize(new
{
gameId = "AUTRE",
version = "3.0",
archive = new { url = "../partage/commun.zip", size = 10, sha256 = new string('d', 64) },
objectsBaseUrl = "http://127.0.0.1/exovivalzombie/objects/",
files = new[] { new { path = "a.bin", size = site.Orphan.Length, sha256 = Sha(site.Orphan) } }
}));
var folder = await owner.GetServerFolderAsync("partage");
Assert.Equal(ServerFileStatus.InUse, Entry(folder, "commun.zip").Status);
Assert.Contains("autrejeu/", Entry(folder, "commun.zip").Reason);
Assert.Equal(ServerFileStatus.Unused, Entry(folder, "libre.zip").Status);
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync("partage/commun.zip", 10))).Status);
// L'objet « orphelin » du premier jeu est utilisé par l'autre : ni supprimable, ni nettoyé.
var orphan = $"exovivalzombie/objects/{Sha(site.Orphan)[..2]}/{Sha(site.Orphan)}.bin";
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync(orphan, site.Orphan.Length))).Status);
var preview = await owner.GetObjectsPruneAsync("EZR");
Assert.Equal(0, preview.OrphanCount);
Assert.True(File.Exists(site.ObjectFile(site.Orphan)));
// Adresse complète d'un AUTRE serveur : rien à protéger ici. Adresse de ce serveur hors du site : idem.
File.WriteAllText(Path.Combine(other, "manifest.json"), JsonSerializer.Serialize(new
{
version = "3.1",
archive = new { url = "https://cdn.example.org/commun.zip" },
files = Array.Empty<object>()
}));
Assert.Equal(ServerFileStatus.Unused, Entry(await owner.GetServerFolderAsync("partage"), "commun.zip").Status);
Assert.Equal(1, (await owner.GetObjectsPruneAsync("EZR")).OrphanCount);
}
[Fact]
public async Task Symbolic_links_are_neither_followed_nor_deleted()
{
if (OperatingSystem.IsWindows()) return; // liens symboliques : droits particuliers sous Windows
await using var context = await StartAsync();
var (api, owner, site) = (context.Api, context.Owner, context.Site);
var outside = Path.Combine(Path.GetTempPath(), "launcher-files-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outside);
File.WriteAllText(Path.Combine(outside, "secret.txt"), "secret");
Directory.CreateSymbolicLink(Path.Combine(site.Root, "lien"), outside);
File.CreateSymbolicLink(Path.Combine(site.Root, "lien.txt"), Path.Combine(outside, "secret.txt"));
var root = await owner.GetServerFolderAsync("");
Assert.Equal(ServerFileStatus.Link, Entry(root, "lien").Status);
Assert.Equal(ServerFileStatus.Link, Entry(root, "lien.txt").Status);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.GetServerFolderAsync("lien"))).Status);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync("lien/secret.txt", 6))).Status);
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.DeleteServerFileAsync("lien.txt", 6))).Status);
Assert.True(File.Exists(Path.Combine(outside, "secret.txt")));
}
[Fact]
public async Task Editors_cannot_see_nor_delete_files()
{
await using var context = await StartAsync();
var (api, owner, site) = (context.Api, context.Owner, context.Site);
var editorAccount = await api.AddAdminAsync(owner, "Éditrice", AdminRoles.Editor);
await using var editor = await api.LoginAsync(editorAccount.Key);
var adminAccount = await api.AddAdminAsync(owner, "Admin", AdminRoles.Admin);
await using var admin = await api.LoginAsync(adminAccount.Key);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.GetServerFolderAsync(""))).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.DeleteServerFileAsync("exovivalzombie/EZR-1.0.zip", 777))).Status);
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.GetObjectsPruneAsync("EZR"))).Status);
Assert.True(File.Exists(Path.Combine(site.Game, "EZR-1.0.zip")));
await admin.GetServerFolderAsync(""); // administrateur : autorisé
Assert.True(AdminRoles.Has(AdminRoles.Admin, AdminPermissions.FilesManage));
}
[Fact]
public async Task Without_any_public_folder_the_page_reports_it_is_not_configured()
{
await using var api = await TestApi.StartAsync();
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.GetServerFolderAsync(""));
Assert.Equal(503, refused.Status);
Assert.Equal(ApiErrorCodes.NotConfigured, refused.Code);
}
[Fact]
public async Task The_audit_can_be_read_up_to_a_date()
{
await using var api = await TestApi.StartAsync();
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var limit = api.Time.GetUtcNow().AddMinutes(1);
// Entrée écrite trois jours plus tard (horloge du serveur avancée le temps de l'écrire).
var audit = api.Services.GetRequiredService<GameLauncher.Server.Storage.AuditLog>();
api.Time.Advance(TimeSpan.FromDays(3));
audit.Write("test.later", AuditResults.Ok, details: "plus tard");
api.Time.Advance(TimeSpan.FromDays(-3));
var all = await owner.GetAuditAsync(50);
Assert.Contains(all.Entries, e => e.Action == "test.later");
var early = await owner.GetAuditAsync(50, before: limit);
Assert.DoesNotContain(early.Entries, e => e.Action == "test.later");
Assert.Contains(early.Entries, e => e.Action == "auth.login");
Assert.All(early.Entries, e => Assert.True(e.Timestamp < limit));
// Date invalide : refus explicite.
var invalid = await Assert.ThrowsAsync<ApiException>(() => owner.GetAsync<AuditPage>("v1/audit?before=pas-une-date"));
Assert.Equal(400, invalid.Status);
}
}
+488
View File
@@ -0,0 +1,488 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
namespace UiSnapshot;
/// <summary>Résultat d'une scène : fichiers produits, problèmes détectés, remarques.</summary>
internal sealed class SceneRecord
{
public SceneRecord(string id, string title)
{
Id = id;
Title = title;
}
public string Id { get; }
public string Title { get; }
public List<string> Files { get; } = new();
public string? Size { get; set; }
/// <summary>Problèmes de mise en page (texte coupé, débordement, image absente…).</summary>
public List<string> Issues { get; } = new();
/// <summary>Erreurs de liaison WPF ({Binding} vers une propriété inexistante…).</summary>
public List<string> Bindings { get; } = new();
/// <summary>Informations (zones qui défilent, messages affichés par l'écran…).</summary>
public List<string> Notes { get; } = new();
public string? Error { get; set; }
public string? Skipped { get; set; }
public TimeSpan Duration { get; set; }
public bool HasProblems => Issues.Count > 0 || Bindings.Count > 0 || Error is not null;
}
/// <summary>Identifiant et titre d'une scène (liste --list).</summary>
internal sealed record SceneInfo(string Id, string Title);
/// <summary>Ensemble de scènes (écrans du joueur, menu d'administration…).</summary>
internal interface ISceneSet
{
IEnumerable<SceneInfo> List(IReadOnlyList<string?> themes);
Task RunAsync(Capture capture);
}
/// <summary>
/// Rendu des fenêtres en PNG et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
/// l'écran (à droite de tous les moniteurs), sans prendre le focus : rien n'apparaît pendant l'exécution.
/// </summary>
internal sealed class Capture
{
private readonly SnapshotOptions _options;
private readonly List<Regex> _filters;
private SceneRecord? _current;
public Capture(SnapshotOptions options, string outputDirectory, IReadOnlyList<string?> themes)
{
_options = options;
OutputDirectory = outputDirectory;
Themes = themes;
_filters = options.Filters.Select(f => new Regex("^" + Regex.Escape(f).Replace(@"\*", ".*").Replace(@"\?", ".") + "$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)).ToList();
}
public string OutputDirectory { get; }
/// <summary>Thèmes à rendre : null = thème naturel de l'écran (celui du jeu).</summary>
public IReadOnlyList<string?> Themes { get; }
public List<SceneRecord> Records { get; } = new();
/// <summary>Remarques hors scène (erreurs survenues entre deux captures…).</summary>
public List<string> GlobalNotes { get; } = new();
public bool Failed => Records.Any(r => r.Error is not null);
public static string Suffix(string? theme) => theme is null ? "" : "@" + theme;
public bool Wants(string id) => _filters.Count == 0 || _filters.Any(f => f.IsMatch(id));
public bool WantsAny(IEnumerable<string> ids) => ids.Any(Wants);
// ------------------------------------------------------------------ Remarques
/// <summary>Remarque attachée à la scène en cours (ou à l'exécution s'il n'y en a pas).</summary>
public void Note(string message)
{
if (_current is { } scene) scene.Notes.Add(message);
else GlobalNotes.Add(message);
}
/// <summary>Problème attaché à la scène en cours (message affiché par l'écran, erreur…).</summary>
public void Problem(string message)
{
if (_current is { } scene) scene.Issues.Add(message);
else GlobalNotes.Add("⚠ " + message);
}
public void OnBindingError(string message)
{
var text = message.Trim();
if (text.Length == 0) return;
if (_current is { } scene)
{
if (!scene.Bindings.Contains(text)) scene.Bindings.Add(text);
}
else if (!GlobalNotes.Contains(text))
{
GlobalNotes.Add("Liaison : " + text);
}
}
public void Skip(string id, string title, string reason)
{
if (!Wants(id)) return;
Records.Add(new SceneRecord(id, title) { Skipped = reason });
Console.WriteLine($" – {id,-44} ignorée : {reason}");
}
// ------------------------------------------------------------------ Scènes
/// <summary>
/// Une scène : <paramref name="prepare"/> construit (ou met dans l'état voulu) la fenêtre, qui est
/// affichée hors de l'écran si besoin, rendue en PNG et analysée ; <paramref name="release"/> la referme
/// (rien pour une fenêtre réutilisée par les scènes suivantes).
/// </summary>
public async Task ShootAsync(string id, string title, Func<Task<Window>> prepare, Func<Window, Task>? release = null)
{
if (!Wants(id)) return;
var record = new SceneRecord(id, title);
Records.Add(record);
_current = record;
var clock = Stopwatch.StartNew();
Window? window = null;
try
{
window = await prepare();
if (!window.IsVisible) ShowOffscreen(window);
await SettleAsync();
await RenderSceneAsync(window, record);
}
catch (Exception ex)
{
record.Error = $"{ex.GetType().Name} : {ex.Message}";
Debug.WriteLine(ex);
}
finally
{
if (window is not null && release is not null)
{
try
{
await release(window);
}
catch (Exception ex)
{
record.Notes.Add($"Fermeture de la fenêtre : {ex.Message}");
}
}
await SettleAsync(30); // erreurs de liaison tardives encore attribuées à cette scène
_current = null;
record.Duration = clock.Elapsed;
WriteConsole(record);
}
}
/// <summary>Ferme une fenêtre créée pour une seule scène.</summary>
public static Task CloseAsync(Window window)
{
window.Close();
return Task.CompletedTask;
}
private static void WriteConsole(SceneRecord record)
{
if (record.Error is not null)
{
Console.WriteLine($" ✗ {record.Id,-44} ÉCHEC : {record.Error}");
return;
}
var problems = record.Issues.Count + record.Bindings.Count;
var text = problems == 0 ? "" : $" {problems} problème(s)";
Console.WriteLine($" ✓ {record.Id,-44} {record.Size,-11} {record.Duration.TotalSeconds,4:0.0} s{text}");
}
/// <summary>Affiche une fenêtre à droite de tous les écrans, sans la rendre active ni l'ajouter à la barre des tâches.</summary>
public static void ShowOffscreen(Window window)
{
window.WindowStartupLocation = WindowStartupLocation.Manual;
window.ShowActivated = false;
window.ShowInTaskbar = false;
// À droite du bureau virtuel et à la hauteur de la zone utile : les fenêtres qui se recadrent
// dans l'écran au chargement (FitToScreen) ne se déplacent pas.
window.Left = SystemParameters.VirtualScreenLeft + SystemParameters.VirtualScreenWidth + 400;
window.Top = SystemParameters.WorkArea.Top;
window.Show();
}
/// <summary>Laisse WPF terminer la mise en page, les liaisons, le chargement des images et les animations d'ouverture.</summary>
public async Task SettleAsync(int? milliseconds = null)
{
await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
await Task.Delay(milliseconds ?? _options.WaitMilliseconds);
await Dispatcher.Yield(DispatcherPriority.ApplicationIdle);
}
// ------------------------------------------------------------------ Rendu
private async Task RenderSceneAsync(Window window, SceneRecord record)
{
var root = RootOf(window);
root.UpdateLayout();
var image = Render(root);
Save(image, record.Id + ".png", record);
record.Size = $"{image.PixelWidth}×{image.PixelHeight}";
Diagnose(root, record);
if (_options.Tree) WriteTree(root, Path.Combine(OutputDirectory, record.Id + ".tree.txt"), record);
if (_options.Scroll) await ScrollPagesAsync(root, record);
}
/// <summary>Racine visuelle de la fenêtre (cadre, ombre et contenu, sans la barre de titre de Windows).</summary>
private static FrameworkElement RootOf(Window window)
{
if (VisualTreeHelper.GetChildrenCount(window) > 0 && VisualTreeHelper.GetChild(window, 0) is FrameworkElement root) return root;
return window.Content as FrameworkElement ?? window;
}
/// <summary>Même technique que la capture du launcher (MainWindow.Snapshot) : pinceau visuel cadré sur l'élément.</summary>
private BitmapSource Render(FrameworkElement element)
{
var width = Math.Max(1, element.ActualWidth);
var height = Math.Max(1, element.ActualHeight);
var scale = _options.Scale;
var bitmap = new RenderTargetBitmap((int)Math.Ceiling(width * scale), (int)Math.Ceiling(height * scale),
96 * scale, 96 * scale, PixelFormats.Pbgra32);
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
if (_options.Backdrop is { } backdrop) dc.DrawRectangle(new SolidColorBrush(backdrop), null, new Rect(0, 0, width, height));
var offset = VisualTreeHelper.GetOffset(element);
var brush = new VisualBrush(element)
{
Stretch = Stretch.None,
AlignmentX = AlignmentX.Left,
AlignmentY = AlignmentY.Top,
ViewboxUnits = BrushMappingMode.Absolute,
Viewbox = new Rect(offset.X, offset.Y, width, height)
};
dc.DrawRectangle(brush, null, new Rect(0, 0, width, height));
}
bitmap.Render(visual);
bitmap.Freeze();
return bitmap;
}
private void Save(BitmapSource image, string fileName, SceneRecord record)
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(image));
using (var stream = File.Create(Path.Combine(OutputDirectory, fileName)))
encoder.Save(stream);
record.Files.Add(fileName);
}
/// <summary>
/// Zone qui défile (la plus grande de l'écran) : une capture par « page » supplémentaire
/// (&lt;scène&gt;.p2.png…, 4 au plus), puis retour en haut.
/// </summary>
private async Task ScrollPagesAsync(FrameworkElement root, SceneRecord record)
{
var scroller = Visible(root).Select(v => v.Element).OfType<ScrollViewer>()
.Where(s => s.ScrollableHeight > 24 && s.ViewportHeight > 60)
.OrderByDescending(s => s.ActualWidth * s.ActualHeight)
.FirstOrDefault();
if (scroller is null) return;
var original = scroller.VerticalOffset;
var step = Math.Max(1, scroller.ViewportHeight * 0.85);
var page = 2;
for (var offset = step; page <= 5; offset += step, page++)
{
var target = Math.Min(offset, scroller.ScrollableHeight);
scroller.ScrollToVerticalOffset(target);
await SettleAsync(160);
Save(Render(root), $"{record.Id}.p{page}.png", record);
if (target >= scroller.ScrollableHeight) break;
}
scroller.ScrollToVerticalOffset(original);
await SettleAsync(60);
}
// ------------------------------------------------------------------ Diagnostic
private readonly record struct VisibleElement(FrameworkElement Element, bool InScroll);
/// <summary>Éléments affichés (sous-arbres masqués ou transparents ignorés), en signalant ceux d'une zone qui défile.</summary>
private static IEnumerable<VisibleElement> Visible(DependencyObject root)
{
var stack = new Stack<(DependencyObject Node, bool InScroll)>();
stack.Push((root, false));
while (stack.Count > 0)
{
var (node, inScroll) = stack.Pop();
if (node is UIElement ui && (ui.Visibility != Visibility.Visible || ui.Opacity <= 0.01)) continue;
if (node is FrameworkElement element) yield return new VisibleElement(element, inScroll);
var childInScroll = inScroll || node is ScrollContentPresenter;
var count = VisualTreeHelper.GetChildrenCount(node);
for (var i = count - 1; i >= 0; i--) stack.Push((VisualTreeHelper.GetChild(node, i), childInScroll));
}
}
private void Diagnose(FrameworkElement root, SceneRecord record)
{
var bounds = new Rect(0, 0, root.ActualWidth, root.ActualHeight);
bounds.Inflate(1, 1);
var outside = 0;
foreach (var (element, inScroll) in Visible(root))
{
switch (element)
{
case TextBlock text:
CheckText(text, root, record);
break;
case ScrollViewer { ScrollableHeight: > 1 } scroller:
record.Notes.Add($"Zone défilante : {scroller.ViewportHeight:0} visibles sur {scroller.ExtentHeight:0} " +
$"({(scroller.CanContentScroll ? "éléments" : "px")}) — {Where(scroller, root)}");
break;
case Image { Source: null } image when image.ActualWidth > 0:
record.Issues.Add($"Image sans source — {Where(image, root)}");
break;
}
// Contenu qui sort de la fenêtre (hors zones qui défilent, dont le contenu est découpé normalement).
if (!inScroll && element is TextBlock or ButtonBase or Image or TextBox or Selector && element.ActualWidth >= 2 && element.ActualHeight >= 2 &&
BoundsIn(element, root) is { } box && !bounds.Contains(box) && ++outside <= 10)
record.Issues.Add($"Dépasse de la fenêtre : {Describe(element)} — {Where(element, root)}");
}
}
/// <summary>Texte coupé : sur une ligne, plus large que l'espace donné ; sur plusieurs, plus haut que l'espace donné.</summary>
private static void CheckText(TextBlock block, FrameworkElement root, SceneRecord record)
{
var text = block.Text;
if (string.IsNullOrWhiteSpace(text) || block.ActualWidth < 1 || text.Length > 2000) return;
if (block.Inlines.Count > 1) return; // texte mis en forme (plusieurs polices) : mesure non fiable
var available = block.ActualWidth - block.Padding.Left - block.Padding.Right;
var pixelsPerDip = VisualTreeHelper.GetDpi(block).PixelsPerDip;
var typeface = new Typeface(block.FontFamily, block.FontStyle, block.FontWeight, block.FontStretch);
// Même mode de rendu du texte que le TextBlock (« Ideal » / « Display ») : sinon les largeurs diffèrent légèrement.
var formatted = new FormattedText(text, CultureInfo.CurrentUICulture, block.FlowDirection, typeface, block.FontSize,
Brushes.Black, new NumberSubstitution(), TextOptions.GetTextFormattingMode(block), pixelsPerDip);
if (block.TextWrapping == TextWrapping.NoWrap)
{
if (formatted.WidthIncludingTrailingWhitespace <= available + 1) return;
var how = block.TextTrimming == TextTrimming.None ? "coupé net" : "raccourci par « … »";
record.Issues.Add($"Texte {how} : « {Short(text)} » — {formatted.WidthIncludingTrailingWhitespace:0} px nécessaires, " +
$"{available:0} disponibles — {Where(block, root)}");
return;
}
formatted.MaxTextWidth = Math.Max(1, available);
if (!double.IsNaN(block.LineHeight) && block.LineHeight > 0) formatted.LineHeight = block.LineHeight;
var availableHeight = block.ActualHeight - block.Padding.Top - block.Padding.Bottom;
if (formatted.Height > availableHeight + 2)
record.Issues.Add($"Texte tronqué en hauteur : « {Short(text)} » — {formatted.Height:0} px nécessaires, " +
$"{availableHeight:0} disponibles — {Where(block, root)}");
}
private static Rect? BoundsIn(FrameworkElement element, FrameworkElement root)
{
try
{
return element.TransformToAncestor(root).TransformBounds(new Rect(0, 0, element.ActualWidth, element.ActualHeight));
}
catch (InvalidOperationException)
{
return null; // élément d'une autre fenêtre (fenêtre surgissante)
}
}
/// <summary>« (x 412, y 88, 180×24) dans Border#Card › StackPanel › TextBlock » : position en unités WPF (× échelle pour le PNG).</summary>
private static string Where(FrameworkElement element, FrameworkElement root)
{
var box = BoundsIn(element, root);
var position = box is { } b ? $"(x {b.X:0}, y {b.Y:0}, {b.Width:0}×{b.Height:0})" : "(position inconnue)";
return $"{position} dans {PathOf(element, root)}";
}
/// <summary>Chemin court : l'élément, ses deux parents, et le plus proche ancêtre nommé.</summary>
private static string PathOf(FrameworkElement element, FrameworkElement root)
{
var parts = new List<string> { Describe(element) };
string? named = null;
DependencyObject? node = VisualTreeHelper.GetParent(element);
var depth = 0;
while (node is not null && !ReferenceEquals(node, root))
{
if (node is FrameworkElement fe)
{
if (depth < 2) parts.Insert(0, Describe(fe));
else if (named is null && !string.IsNullOrEmpty(fe.Name)) named = Describe(fe);
depth++;
}
node = VisualTreeHelper.GetParent(node);
}
if (named is not null) parts.Insert(0, named + " › …");
return string.Join(" › ", parts);
}
private static string Describe(FrameworkElement element)
{
var name = string.IsNullOrEmpty(element.Name) ? "" : "#" + element.Name;
var content = element switch
{
ContentControl { Content: string text } => $" « {Short(text, 30)} »",
_ => ""
};
return element.GetType().Name + name + content;
}
private static string Short(string text, int max = 60)
{
var single = text.Replace("\r", " ").Replace("\n", " ").Trim();
return single.Length <= max ? single : single[..(max - 1)] + "…";
}
// ------------------------------------------------------------------ Arbre visuel
/// <summary>
/// Arbre des éléments utiles (nommés, contrôles, textes, images, listes) avec position et texte :
/// permet de retrouver dans le XAML l'élément vu sur la capture.
/// </summary>
private static void WriteTree(FrameworkElement root, string path, SceneRecord record)
{
var sb = new StringBuilder();
sb.AppendLine($"# {record.Id} — {record.Title}");
sb.AppendLine("# Position en unités WPF (multiplier par l'échelle pour les pixels du PNG).");
void Visit(DependencyObject node, int depth)
{
if (node is UIElement ui && (ui.Visibility != Visibility.Visible)) return;
var next = depth;
if (node is FrameworkElement fe && IsInteresting(fe))
{
sb.Append(' ', depth * 2).Append(Describe(fe));
if (BoundsIn(fe, root) is { } b) sb.Append(CultureInfo.InvariantCulture, $" [{b.X:0},{b.Y:0} {b.Width:0}×{b.Height:0}]");
var text = fe switch
{
TextBlock t => t.Text,
TextBox t => t.Text,
_ => null
};
if (!string.IsNullOrWhiteSpace(text)) sb.Append(" « ").Append(Short(text, 90)).Append(" »");
if (!fe.IsEnabled) sb.Append(" (désactivé)");
if (fe.Opacity < 1) sb.Append(CultureInfo.InvariantCulture, $" (opacité {fe.Opacity:0.##})");
if (fe is ToggleButton { IsChecked: true }) sb.Append(" (coché)");
if (fe is Selector { SelectedIndex: >= 0 } selector) sb.Append(CultureInfo.InvariantCulture, $" (sélection n° {selector.SelectedIndex})");
sb.AppendLine();
next = depth + 1;
}
var count = VisualTreeHelper.GetChildrenCount(node);
for (var i = 0; i < count; i++) Visit(VisualTreeHelper.GetChild(node, i), next);
}
Visit(root, 0);
File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
record.Files.Add(Path.GetFileName(path));
}
private static bool IsInteresting(FrameworkElement element) =>
!string.IsNullOrEmpty(element.Name) || element is Control or TextBlock or Image or ItemsPresenter ||
element is Panel { Children.Count: > 1 } && element.Parent is not null;
}
+620
View File
@@ -0,0 +1,620 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Models;
namespace UiSnapshot.Fakes;
/// <summary>
/// Launcher API simulée, EN MÉMOIRE (gestionnaire HTTP du client de l'API : aucune connexion réseau).
/// Elle répond avec des données de démonstration cohérentes (administrateurs, appareils, journal,
/// catalogue, notes de version, signatures) et accepte les modifications sans rien enregistrer.
/// Signatures et jetons ne sont pas vérifiés : seul l'affichage compte ici.
/// Une route inconnue (module ajouté depuis) répond 404 et est signalée dans le rapport.
/// </summary>
internal sealed class FakeApi : HttpMessageHandler
{
public const string BaseUrl = "https://admin.exemple.test/api/";
public const string Origin = "https://admin.exemple.test";
private readonly SampleWorld _world;
private readonly object _sync = new();
private int _inFlight;
public FakeApi(SampleWorld world) => _world = world;
/// <summary>Requêtes en cours (attente de la fin du chargement d'une page).</summary>
public int InFlight => Volatile.Read(ref _inFlight);
/// <summary>Routes demandées mais non simulées (« GET v1/news »…).</summary>
public List<string> Unhandled { get; } = new();
/// <summary>Délai simulé de chaque réponse (les indicateurs « chargement » apparaissent comme en vrai).</summary>
public TimeSpan Latency { get; set; } = TimeSpan.FromMilliseconds(20);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _inFlight);
try
{
await Task.Delay(Latency, cancellationToken).ConfigureAwait(false);
var body = request.Content is null ? Array.Empty<byte>() : await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var path = request.RequestUri?.AbsolutePath ?? "";
var route = path.StartsWith("/api/", StringComparison.Ordinal) ? path["/api/".Length..] : path.TrimStart('/');
try
{
return Handle(request.Method, route, body, request.RequestUri?.Query ?? "");
}
catch (Exception ex) when (ex is JsonException or ArgumentException or FormatException)
{
return Error(HttpStatusCode.BadRequest, ApiErrorCodes.InvalidRequest, $"Requête refusée par l'API simulée : {ex.Message}");
}
}
finally
{
Interlocked.Decrement(ref _inFlight);
}
}
private HttpResponseMessage Handle(HttpMethod method, string route, byte[] body, string query)
{
var get = method == HttpMethod.Get;
var segments = route.Split('/', StringSplitOptions.RemoveEmptyEntries);
switch (segments)
{
// ---- Système
case ["v1", "info"] when get:
return Json(_world.Info());
case ["v1", "health"] when get:
return Json(new HealthStatus());
// ---- Authentification
case ["v1", "auth", "challenge"]:
var challenge = Read<ChallengeRequest>(body);
return Json(_world.Challenge(challenge.KeyId));
case ["v1", "auth", "login"]:
var login = Read<LoginRequest>(body);
return Json(_world.Login(login.KeyId));
case ["v1", "auth", "me"] when get:
return Json(_world.Session());
case ["v1", "auth", "logout"]:
return NoContent();
case ["v1", "enrollment"]:
return Json(_world.Enroll(Read<EnrollmentRequest>(body)));
// ---- Administrateurs
case ["v1", "admins"] when get:
return Json(_world.Admins());
case ["v1", "admins", "me"] when get:
return Json(_world.MyAccount());
case ["v1", "admins", "invitations"] when get:
return Json(_world.Invitations());
case ["v1", "admins", "invitations"]:
return Json(_world.CreateInvitation(Read<CreateInvitationRequest>(body)));
case ["v1", "admins", "invitations", _]:
return NoContent();
case ["v1", "admins", var adminId, ..] when Guid.TryParse(adminId, out var id):
return _world.Admins().FirstOrDefault(a => a.Id == id) is { } admin
? Json(admin)
: Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Administrateur introuvable.");
// ---- Journal d'audit
case ["v1", "audit"] when get:
return Json(_world.Audit());
case ["v1", "audit", "export"]:
return NoContent();
// ---- Catalogue
case ["v1", "catalog"] when get:
return Json(_world.Catalog());
case ["v1", "catalog"]:
var update = Read<CatalogUpdateRequest>(body);
return Json(_world.Catalog() with { Catalog = update.Catalog with { Revision = update.BaseRevision + 1, UpdatedAt = DateTimeOffset.Now } });
// ---- Notes de version
case ["v1", "games", var gameId, "changelog"] when get:
return Json(_world.Changelog(gameId));
case ["v1", "games", var gameId, "changelog", _]:
return Json(_world.Changelog(gameId));
// ---- Signatures
case ["v1", "games", var gameId, "signature"]:
return Json(_world.ManifestSignature(gameId));
case ["v1", "signing"] when get:
return Json(_world.Signing());
case ["v1", "keyring"]:
return Json(Read<KeyringDocument>(body));
// ---- Publication (la page se contente de lire l'état ; aucune version n'est envoyée par l'outil)
case ["v1", "games", var gameId, "publications"]:
return Json(_world.Publication(gameId));
case ["v1", "publications", var publicationId] when get:
return Json(_world.Publication(_world.Games.FirstOrDefault()?.GameId ?? "", publicationId));
case ["v1", "publications", _] when method == HttpMethod.Delete:
return NoContent();
// ---- Fichiers du serveur
case ["v1", "files"] when get:
return Json(_world.Files(QueryValue(query, "path") ?? ""));
case ["v1", "files", "delete"]:
var deletion = Read<ServerFileDeleteRequest>(body);
return Json(new ServerFileDeleteResult { Path = deletion.Path, FreedBytes = deletion.Size });
case ["v1", "games", var gameId, "objects", "prune"] when get:
return Json(_world.PrunePreview(gameId));
case ["v1", "games", var gameId, "objects", "prune"]:
return Json(new ObjectsPruneResult { GameId = gameId, Deleted = 412, FreedBytes = 1_873_000_000 });
}
var description = $"{method.Method} {route}";
lock (_sync)
{
if (!Unhandled.Contains(description)) Unhandled.Add(description);
}
return Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, $"Route non simulée par UiSnapshot : {description} (à ajouter dans Fakes\\FakeApi.cs).");
}
// ------------------------------------------------------------------ Réponses
private static string? QueryValue(string query, string name)
{
foreach (var part in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var pair = part.Split('=', 2);
if (pair[0] == name) return Uri.UnescapeDataString(pair.Length > 1 ? pair[1] : "");
}
return null;
}
private static T Read<T>(byte[] body) =>
JsonSerializer.Deserialize<T>(body, ApiJson.Options) ?? throw new JsonException("corps vide");
private static HttpResponseMessage Json<T>(T value)
{
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(value, ApiJson.Options))
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
return response;
}
private static HttpResponseMessage NoContent() => new(HttpStatusCode.NoContent) { Content = new ByteArrayContent(Array.Empty<byte>()) };
private static HttpResponseMessage Error(HttpStatusCode status, string code, string message)
{
var response = Json(new ApiError { Status = (int)status, Code = code, Message = message });
response.StatusCode = status;
return response;
}
}
/// <summary>
/// Monde de démonstration du menu d'administration : quatre administrateurs (propriétaire connecté,
/// administrateur, éditeur en attente d'approbation, compte révoqué), leurs appareils, des invitations,
/// un journal d'audit varié, le catalogue, les notes de version et l'état des signatures.
/// </summary>
internal sealed class SampleWorld
{
private static readonly Guid OwnerId = Guid.Parse("6f1c2d3e-4a5b-4c6d-8e7f-90a1b2c3d4e5");
private static readonly Guid AdminId = Guid.Parse("7a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d");
private static readonly Guid EditorId = Guid.Parse("8b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e");
private static readonly Guid RevokedId = Guid.Parse("9c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f");
private readonly byte[] _ownerKey;
private readonly byte[] _ownerLaptopKey;
private readonly byte[] _adminKey;
private readonly byte[] _revokedKey;
private readonly byte[] _newDeviceKey;
private readonly ECDsa _root;
private readonly DateTimeOffset _now = DateTimeOffset.Now;
/// <param name="ownerPublicKey">Clé publique (SPKI) de la clé « fichier » utilisée pour se connecter.</param>
/// <param name="root">Clé racine de démonstration (signe le trousseau ; sa clé publique est donnée au menu comme clé intégrée).</param>
public SampleWorld(byte[] ownerPublicKey, IReadOnlyList<LauncherConfig> games, ECDsa root)
{
_ownerKey = ownerPublicKey;
Games = games;
_root = root;
_ownerLaptopKey = NewPublicKey();
_adminKey = NewPublicKey();
_revokedKey = NewPublicKey();
_newDeviceKey = NewPublicKey();
}
public IReadOnlyList<LauncherConfig> Games { get; }
public const string ServerId = "c1f0a7e2b94d4b6e8a3f5d2c7b1e9a40";
public const string OwnerName = "Nicolas";
public string OwnerKeyId => AdminKeys.ComputeKeyId(_ownerKey);
private static byte[] NewPublicKey()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
return key.ExportSubjectPublicKeyInfo();
}
// ------------------------------------------------------------------ Système et session
public ServerInfo Info() => new()
{
ServerId = ServerId,
Origin = FakeApi.Origin,
ApiVersion = ApiRoutes.Version,
ServerVersion = "1.7.0",
KeyAlgorithms = new[] { "ECDSA-P256-SHA256" }
};
public ChallengeResponse Challenge(string keyId) => new()
{
ChallengeId = Random(16),
Nonce = Random(32),
ServerId = ServerId,
Origin = FakeApi.Origin,
ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(60),
ServerTime = DateTimeOffset.UtcNow
};
public LoginResponse Login(string keyId) => new()
{
Token = Random(32),
SessionId = Random(16),
ExpiresAt = DateTimeOffset.UtcNow.AddHours(8),
IdleExpiresAt = DateTimeOffset.UtcNow.AddMinutes(20),
Admin = Profile()
};
public SessionInfo Session() => new()
{
SessionId = Random(16),
ExpiresAt = DateTimeOffset.UtcNow.AddHours(8),
IdleExpiresAt = DateTimeOffset.UtcNow.AddMinutes(20),
Admin = Profile()
};
private AdminProfile Profile() => new()
{
Id = OwnerId,
DisplayName = OwnerName,
Role = AdminRoles.Owner,
Permissions = AdminRoles.PermissionsOf(AdminRoles.Owner),
KeyId = OwnerKeyId,
KeyLabel = "PC-BUREAU"
};
public EnrollmentResponse Enroll(EnrollmentRequest request)
{
var key = AdminKeys.ParsePublicKey(request.PublicKey);
return new EnrollmentResponse
{
AdminId = Guid.NewGuid(),
DisplayName = "Léa",
KeyId = AdminKeys.ComputeKeyId(key),
Fingerprint = AdminKeys.ComputeFingerprint(key),
Status = AdminStatus.Pending
};
}
// ------------------------------------------------------------------ Administrateurs
public List<AdminSummary> Admins() => new()
{
Owner(),
new AdminSummary
{
Id = AdminId,
DisplayName = "Camille",
Role = AdminRoles.Admin,
Status = AdminStatus.Active,
CreatedAt = _now.AddDays(-40),
Keys = new[] { ActiveKey(_adminKey, "PC-STUDIO", -40, -1) }
},
new AdminSummary
{
Id = EditorId,
DisplayName = "Léo",
Role = AdminRoles.Editor,
Status = AdminStatus.Pending,
CreatedAt = _now.AddHours(-5),
Keys = new[]
{
new AdminKeySummary
{
KeyId = AdminKeys.ComputeKeyId(_newDeviceKey),
Label = "Portable de Léo",
Status = AdminStatus.Pending,
AddedAt = _now.AddHours(-5)
}
}
},
new AdminSummary
{
Id = RevokedId,
DisplayName = "Sam",
Role = AdminRoles.Editor,
Status = AdminStatus.Revoked,
CreatedAt = _now.AddDays(-90),
Keys = new[]
{
new AdminKeySummary
{
KeyId = AdminKeys.ComputeKeyId(_revokedKey),
Label = "PC-SAM",
Status = AdminStatus.Revoked,
Fingerprint = AdminKeys.ComputeFingerprint(_revokedKey),
AddedAt = _now.AddDays(-90),
LastUsedAt = _now.AddDays(-31)
}
}
}
};
private AdminSummary Owner() => new()
{
Id = OwnerId,
DisplayName = OwnerName,
Role = AdminRoles.Owner,
Status = AdminStatus.Active,
CreatedAt = _now.AddDays(-120),
Keys = new[]
{
ActiveKey(_ownerKey, "PC-BUREAU", -120, 0),
ActiveKey(_ownerLaptopKey, "Portable", -60, -6)
}
};
private AdminKeySummary ActiveKey(byte[] publicKey, string label, int addedDays, int lastUsedDays) => new()
{
KeyId = AdminKeys.ComputeKeyId(publicKey),
Label = label,
Status = AdminStatus.Active,
Fingerprint = AdminKeys.ComputeFingerprint(publicKey),
PublicKey = Convert.ToBase64String(publicKey),
AddedAt = _now.AddDays(addedDays),
LastUsedAt = _now.AddDays(lastUsedDays).AddMinutes(-17)
};
public List<InvitationSummary> Invitations() => new()
{
new InvitationSummary
{
Id = Guid.Parse("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"),
DisplayName = "Alex",
Role = AdminRoles.Editor,
CreatedAt = _now.AddHours(-3),
CreatedBy = OwnerName,
CreatedById = OwnerId,
ExpiresAt = _now.AddHours(21)
},
NewDeviceInvitation()
};
private InvitationSummary NewDeviceInvitation() => new()
{
Id = Guid.Parse("b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e"),
DisplayName = OwnerName,
Role = AdminRoles.Owner,
ForAdminId = OwnerId,
CreatedAt = _now.AddHours(-1),
CreatedBy = OwnerName,
CreatedById = OwnerId,
ExpiresAt = _now.AddHours(23)
};
public MyAccount MyAccount() => new() { Admin = Owner(), Invitations = new[] { NewDeviceInvitation() } };
public InvitationCreated CreateInvitation(CreateInvitationRequest request) => new()
{
Invitation = new InvitationSummary
{
Id = Guid.NewGuid(),
DisplayName = request.DisplayName ?? OwnerName,
Role = request.Role ?? AdminRoles.Editor,
ForAdminId = request.ForAdminId,
CreatedAt = _now,
CreatedBy = OwnerName,
CreatedById = OwnerId,
ExpiresAt = _now.AddHours(24)
},
Code = InvitationCodes.Generate()
};
// ------------------------------------------------------------------ Fichiers du serveur
public ServerFolder Files(string path)
{
var game = Games.FirstOrDefault()?.GameId ?? "EZR";
ServerFileEntry Dir(string name, long size, long count) => new()
{
Name = name, IsDirectory = true, Size = size, FileCount = count, Modified = _now.AddDays(-2), Status = ServerFileStatus.Directory
};
ServerFileEntry FileEntry(string name, long size, string status, string? reason, double daysAgo) => new()
{
Name = name, Size = size, FileCount = 1, Modified = _now.AddDays(-daysAgo), Status = status, Reason = reason
};
var entries = path switch
{
"" => new List<ServerFileEntry>
{
Dir("catalog", 2_400_000, 6), Dir("exovivalzombie", 9_870_000_000, 18_204), Dir("launcher", 412_000_000, 5)
},
"exovivalzombie" => new List<ServerFileEntry>
{
Dir("objects", 5_120_000_000, 18_201),
FileEntry("EZR-1.1.0.zip", 2_310_000_000, ServerFileStatus.Unused, null, 40),
FileEntry("EZR-1.2.0.zip", 2_440_000_000, ServerFileStatus.InUse, "archive de la version 1.2.0 (manifest de exovivalzombie/).", 3),
FileEntry("manifest.json", 3_400_000, ServerFileStatus.Protected, "manifest d'un jeu : jamais supprimé.", 3)
},
"launcher" => new List<ServerFileEntry>
{
FileEntry("GameLauncher-2.0.0.exe", 138_000_000, ServerFileStatus.Unused, null, 6),
FileEntry("GameLauncher-2.1.0.exe", 139_000_000, ServerFileStatus.InUse, "exécutable du launcher 2.1.0 (launcher-update.json de launcher/).", 1),
FileEntry("launcher-update.json", 180_000, ServerFileStatus.Protected, "fichier de description (.json) : jamais supprimé depuis le menu.", 1)
},
_ => new List<ServerFileEntry>()
};
return new ServerFolder
{
Path = path,
RootName = "www",
Entries = entries,
FreeBytes = 21_400_000_000,
TotalBytes = 80_000_000_000,
PrunableGames = new List<string> { game }
};
}
public ObjectsPrunePreview PrunePreview(string gameId) => new()
{
GameId = gameId,
Version = "1.2.0",
ManifestSha256 = new string('c', 64),
ReferencedCount = 17_789,
OrphanCount = 412,
OrphanBytes = 1_873_000_000,
Examples = new List<string> { "objects/0a/" + new string('0', 64) + ".bin" }
};
// ------------------------------------------------------------------ Journal d'audit
public AuditPage Audit()
{
var game = Games.FirstOrDefault()?.GameId ?? "EZR";
var entries = new List<AuditEntry>();
void Add(double hoursAgo, string action, string result, string? actor, string? target = null, string? details = null,
string ip = "203.0.113.24")
{
entries.Add(new AuditEntry
{
Timestamp = _now.AddHours(-hoursAgo),
Action = action,
Result = result,
ActorId = actor switch { OwnerName => OwnerId, "Camille" => AdminId, "Léo" => EditorId, _ => null },
ActorName = actor,
KeyId = actor is null ? null : OwnerKeyId,
Ip = ip,
Target = target,
Details = details
});
}
Add(0.05, "audit.export", AuditResults.Ok, OwnerName, "fichier CSV", "245 entrée(s) ; du 01/11/2026 au 07/11/2026 ; filtres : catégorie « Connexions »");
Add(0.08, "audit.view", AuditResults.Ok, OwnerName, details: "200 entrée(s) lue(s)");
Add(0.1, "auth.login", AuditResults.Ok, OwnerName, details: "GameLauncher (PC-BUREAU)");
Add(1, "admins.invite", AuditResults.Ok, OwnerName, "Nicolas", "nouvel appareil pour Nicolas");
Add(3, "admins.invite", AuditResults.Ok, OwnerName, "Alex", "Alex (editor)");
Add(5, "auth.enroll", AuditResults.Ok, null, "Léo", "clé en attente d'approbation", "198.51.100.7");
Add(6, "auth.login", AuditResults.Denied, null, details: "signature invalide", ip: "2001:db8:4f2a::/64");
Add(6.1, "audit.suppressed", AuditResults.Denied, null, details: "37 tentative(s) refusée(s) sans authentification non détaillée(s) (afflux de requêtes)", ip: "2001:db8:4f2a::/64");
Add(20, "catalog.publish", AuditResults.Ok, "Camille", "catalogue", "révision 12 : " + game);
Add(26, "changelog.edit", AuditResults.Ok, "Camille", $"{game} 1.4.2");
Add(49, "release.publish", AuditResults.Ok, OwnerName, $"{game} 1.4.2", "1 842 objets ajoutés, archive envoyée (3,18 Go)");
Add(49.5, "release.start", AuditResults.Ok, OwnerName, $"{game} 1.4.2");
Add(72, "keyring.publish", AuditResults.Ok, OwnerName, "trousseau n° 3", "+1 clé (Camille — PC-STUDIO)");
Add(75, "admins.role", AuditResults.Ok, OwnerName, "Camille", "editor → admin");
Add(98, "admins.revoke", AuditResults.Ok, OwnerName, "Sam");
Add(99, "auth.login", AuditResults.Denied, "Sam", details: "clé révoquée", ip: "192.0.2.55");
Add(120, "admins.approve_key", AuditResults.Ok, OwnerName, "Camille", "PC-STUDIO");
Add(121, "changelog.edit", AuditResults.Failed, "Camille", $"{game} 1.4.1", "le manifest a changé entre-temps (409)");
for (var i = 0; i < 18; i++)
Add(130 + i * 7, i % 3 == 0 ? "catalog.publish" : "auth.login", AuditResults.Ok, i % 2 == 0 ? OwnerName : "Camille",
i % 3 == 0 ? "catalogue" : null);
return new AuditPage { Entries = entries, NextCursor = null };
}
// ------------------------------------------------------------------ Catalogue, notes de version, publication
public CatalogState Catalog() => new()
{
Catalog = SampleData.CreateCatalog(Games) with { Signature = Signature(-3) },
PublishingEnabled = true,
PublicUrl = FakeApi.Origin + "/catalog/catalog.json"
};
public ChangelogState Changelog(string gameId)
{
var entries = SampleData.CreateChangelog();
return new ChangelogState
{
GameId = gameId,
ManifestVersion = entries[0].Version,
ManifestSha256 = SampleData.Digest("manifest " + gameId),
Entries = entries
};
}
public PublicationState Publication(string gameId, string? id = null) => new()
{
PublicationId = id ?? Random(12).ToLowerInvariant(),
GameId = gameId,
Version = "1.5.0",
PublishedVersion = "1.4.2",
ExpiresAt = _now.AddHours(48)
};
// ------------------------------------------------------------------ Signatures
public ManifestSignatureState ManifestSignature(string gameId) => new()
{
GameId = gameId,
Version = "1.4.2",
Digest = SampleData.Digest("contenu " + gameId),
Signature = Signature(-2),
SignerName = $"{OwnerName} — PC-BUREAU"
};
public SigningState Signing()
{
// Trousseau n° 3 signé par la clé racine de démonstration, sans l'appareil « Portable » du propriétaire :
// la page montre ainsi une clé à ajouter au prochain trousseau.
var keys = Admins().Where(a => a.Status == AdminStatus.Active)
.SelectMany(a => a.Keys.Where(k => k.Status == AdminStatus.Active && k.PublicKey is not null && k.Label != "Portable")
.Select(k => new KeyringKey
{
KeyId = k.KeyId,
PublicKey = k.PublicKey!,
Name = $"{a.DisplayName} — {k.Label}",
Scopes = SigningScopes.FromPermissions(AdminRoles.PermissionsOf(a.Role)).ToList()
}))
.Where(k => k.Scopes.Count > 0)
.ToList();
var keyring = Keyring.Sign(new KeyringDocument { Sequence = 3, IssuedAt = _now.AddDays(-3), Keys = keys }, _root);
return new SigningState
{
Keyring = keyring,
KeyringPublishingEnabled = true,
KeyringPublicUrl = FakeApi.Origin + "/catalog/keyring.json",
RequireSignatures = true,
Manifests = Games.Select(g => ManifestSignature(g.GameId)).ToList(),
Catalog = new CatalogSignatureState
{
Revision = 12,
Digest = SampleData.Digest("catalogue"),
Signature = Signature(-3),
SignerName = "Camille — PC-STUDIO"
}
};
}
private ContentSignature Signature(int days) => new()
{
KeyId = OwnerKeyId,
SignedAt = DateTimeOffset.FromUnixTimeSeconds(_now.AddDays(days).ToUnixTimeSeconds()),
Value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64))
};
private static string Random(int bytes) => Base64Url.Encode(RandomNumberGenerator.GetBytes(bytes));
}
+133
View File
@@ -0,0 +1,133 @@
using System.Security.Cryptography;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Services;
using GameLauncher.ViewModels;
namespace UiSnapshot.Fakes;
/// <summary>Registre simulé : rien n'est lu ni écrit dans le registre de Windows.</summary>
internal sealed class FakeRegistry : IRegistryService
{
public GameRegistration? Read() => null;
public void Write(GameRegistration registration, long estimatedSizeBytes) { }
public void RefreshLauncherPath() { }
public void Delete() { }
public void DeleteGameUserSettings() { }
}
/// <summary>Raccourcis simulés : le Bureau n'est jamais modifié.</summary>
internal sealed class FakeShortcuts : IShortcutService
{
public string LauncherPath => @"C:\LanisysGames\GameLauncher.exe";
public string ShortcutName => "Exovival Zombie Reborn";
public bool CreateDesktopShortcut() => true;
public void RefreshDesktopShortcut() { }
public IReadOnlyList<DesktopShortcut> FindDesktopShortcuts() => Array.Empty<DesktopShortcut>();
public bool RetargetDesktopShortcut(DesktopShortcut shortcut) => true;
public int RenameDesktopShortcuts() => 0;
public void RemoveDesktopShortcut() { }
}
/// <summary>Fenêtre et système simulés : aucune fenêtre réduite, aucun dossier ni site ouvert.</summary>
internal sealed class FakeWindowService : IWindowService
{
public void Minimize() { }
public void HideToTray() { }
public void Restore() { }
public void Shutdown() { }
public void OpenFolder(string path) { }
public void OpenUrl(string url) { }
}
/// <summary>
/// Boîtes de dialogue du launcher et des modules : jamais affichées (elles bloqueraient l'outil), mais
/// signalées dans le rapport de la scène en cours. Les confirmations sont refusées (aucune action).
/// </summary>
internal sealed class RecordingDialogs : IDialogService, IModuleDialogs
{
private readonly Action<string> _report;
public RecordingDialogs(Action<string> report) => _report = report;
// ---- Launcher (IDialogService)
public void Show(string title, string message, DialogKind kind = DialogKind.Info) =>
_report($"Boîte de dialogue ({kind}) : {title} — {message}");
public bool Confirm(string title, string message, string confirmText = "Confirmer", string cancelText = "Annuler",
DialogKind kind = DialogKind.Question)
{
_report($"Confirmation demandée (refusée) : {title}");
return false;
}
public (bool Confirmed, bool OptionChecked) ConfirmWithOption(string title, string message, string optionText, string confirmText,
string cancelText, DialogKind kind)
{
_report($"Confirmation demandée (refusée) : {title}");
return (false, false);
}
public string? PickFolder(string title, string? initialDirectory) => null;
public InstallOptions? AskInstallOptions(string gameName, string defaultDirectory, long downloadBytes, long requiredBytes) => null;
public bool ShowSettings(SettingsViewModelBase settings) => false;
// ---- Modules (IModuleDialogs)
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) =>
_report($"Message du menu d'administration ({kind}) : {title} — {message}");
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false)
{
_report($"Confirmation demandée (refusée) : {title}");
return false;
}
public string? PickOpenFile(string title, string filter, string? initialDirectory) => null;
public string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory) => null;
public void CopyToClipboard(string text) { } // le presse-papiers de l'utilisateur n'est pas touché
public void OpenFolder(string path) { }
}
/// <summary>Mise à jour du launcher simulée (paramètres du launcher, ViewModels des jeux).</summary>
internal sealed class FakeLauncherUpdates : ILauncherUpdateService
{
public FakeLauncherUpdates(string currentVersion) => CurrentVersion = currentVersion;
public string CurrentVersion { get; }
public bool IsEnabled => true;
public string? DisabledReason => null;
public bool IsInstalling => false;
public bool AutoUpdate { get; set; } = true;
public Task<LauncherUpdateCheck> CheckAsync(CancellationToken ct = default) =>
Task.FromResult(new LauncherUpdateCheck(VersionUtil.Parse(CurrentVersion), null, null));
public Task<bool> InstallAsync(LauncherUpdateCheck update, bool automatic) => Task.FromResult(false);
}
/// <summary>
/// Puce TPM simulée « disponible » (écran « J'ai une invitation » : choix de la puce dégrisé). Aucune
/// clé n'y est créée : les scènes utilisent des clés « fichier » temporaires.
/// </summary>
internal sealed class FakeTpm : IAdminTpm
{
public TpmAvailability Check() => new(true, "Puce de sécurité TPM 2.0 disponible (simulée par UiSnapshot).");
public IDisposableAdminSigner CreateKey(string keyName, string pin) =>
throw new CryptographicException("Puce simulée : aucune clé n'y est créée (UiSnapshot).");
public IDisposableAdminSigner OpenKey(string keyName, byte[] expectedPublicKey, string pin) =>
throw new CryptographicException("Puce simulée : aucune clé à ouvrir (UiSnapshot).");
public void DeleteKey(string keyName) { }
public TpmLockoutInfo? GetLockoutInfo() => null;
}
+258
View File
@@ -0,0 +1,258 @@
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
namespace UiSnapshot.Fakes;
/// <summary>
/// Contenus de démonstration (notes de version, catalogue, version à publier). Volontairement variés :
/// textes longs, étiquettes, sous-points, commentaires, ancien format… pour éprouver la mise en page.
/// </summary>
internal static class SampleData
{
public static readonly DateTimeOffset Today = new(DateTime.Today, TimeZoneInfo.Local.GetUtcOffset(DateTime.Today));
// ------------------------------------------------------------------ Notes de version du jeu
/// <summary>Notes de version (nouvelles instances à chaque appel : les écrans peuvent les modifier).</summary>
public static List<ChangelogEntry> CreateChangelog()
{
var latest = new ChangelogEntry
{
Version = "1.4.2",
Date = Today.AddDays(-20),
Title = "Niveau 1 « L'école »",
Subtitle = "Mise à jour du " + Today.AddDays(-20).ToString("d MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("fr-FR")),
Introduction =
{
"Merci pour vos retours sur la démo ! Cette version améliore la progression dans l'école et corrige les blocages signalés sur Discord.",
"Les sauvegardes existantes restent compatibles."
},
Sections =
{
new ChangelogSection
{
Title = "PROGRESSION",
Items =
{
new ChangelogItem
{
Tag = "NOUVEAU",
Text = "Sauvegarde automatique à chaque salle de classe débloquée.",
Details = { "La sauvegarde se déclenche aussi en quittant le jeu.", "Trois emplacements de sauvegarde au lieu d'un." }
},
new ChangelogItem
{
Tag = "AMÉLIORÉ",
Text = "Le groupe électrogène du sous-sol se répare désormais en deux étapes.",
Note = "Le courant revient dans tout le rez-de-chaussée une fois les deux fusibles remplacés."
},
new ChangelogItem { Type = ChangelogItem.CommentType, Text = "Le gymnase arrive dans la prochaine mise à jour." }
}
},
new ChangelogSection
{
Title = "CORRECTIFS",
Items =
{
new ChangelogItem { Tag = "CORRIGÉ", Text = "Les zombies ne traversent plus la porte de la bibliothèque." },
new ChangelogItem
{
Tag = "CORRIGÉ",
Text = "La lampe torche ne s'éteint plus d'elle-même quand on change d'arme pendant une animation de rechargement " +
"dans un couloir sombre (texte volontairement long pour vérifier le retour à la ligne)."
},
new ChangelogItem { Text = "Meilleures performances dans la cour de récréation." }
}
}
}
};
latest.Changes = latest.ToFlatChanges();
var previous = new ChangelogEntry
{
Version = "1.4.1",
Date = Today.AddDays(-35),
Title = "Correctifs",
Sections =
{
new ChangelogSection
{
Items =
{
new ChangelogItem { Tag = "CORRIGÉ", Text = "Plantage au chargement d'une sauvegarde dans le réfectoire." },
new ChangelogItem { Tag = "CORRIGÉ", Text = "Sous-titres décalés dans la cinématique d'introduction." },
new ChangelogItem { Text = "Traductions complétées." }
}
}
}
};
previous.Changes = previous.ToFlatChanges();
var older = new ChangelogEntry
{
Version = "1.4.0",
Date = Today.AddDays(-50),
Title = "Le grand retour",
Introduction = { "Première version publique du niveau de l'école." },
Sections =
{
new ChangelogSection
{
Title = "CONTENU",
Items =
{
new ChangelogItem { Tag = "NOUVEAU", Text = "Niveau complet de l'école (12 salles, 3 énigmes)." },
new ChangelogItem { Tag = "NOUVEAU", Text = "Mode hors ligne." }
}
}
}
};
older.Changes = older.ToFlatChanges();
// Ancien format (liste à plat, manifests publiés avant le format enrichi)
var legacy = new ChangelogEntry
{
Version = "1.3.0",
Date = Today.AddDays(-80),
Changes = { "Ajout du mode hors ligne", "Meilleures performances", "Corrections diverses" }
};
return new List<ChangelogEntry> { latest, previous, older, legacy };
}
/// <summary>Notes de la nouvelle version du launcher (fenêtre « Mise à jour du launcher »).</summary>
public static LauncherRelease CreateLauncherRelease(Version current)
{
var next = new Version(current.Major, current.Minor + 1, 0);
var entry = new ChangelogEntry
{
Version = VersionUtil.Display(next),
Date = Today.AddDays(-1),
Title = $"Launcher {next.Major}.{next.Minor}",
Sections =
{
new ChangelogSection
{
Items =
{
new ChangelogItem { Tag = "NOUVEAU", Text = "Vérification des signatures des versions publiées." },
new ChangelogItem { Tag = "AMÉLIORÉ", Text = "Téléchargements plus rapides sur les connexions lentes." },
new ChangelogItem { Tag = "CORRIGÉ", Text = "La fenêtre ne s'ouvre plus en dehors de l'écran après un changement de moniteur." }
}
}
}
};
entry.Changes = entry.ToFlatChanges();
return new LauncherRelease
{
Product = "GameLauncher",
Version = entry.Version,
File = "GameLauncher.exe",
Size = 38_200_000,
Sha256 = new string('0', 64),
PublishedAt = entry.Date,
Changelog = { entry }
};
}
// ------------------------------------------------------------------ Catalogue (informations modifiables)
/// <summary>Catalogue publié : présentation et liens pour chaque jeu déclaré.</summary>
public static CatalogDocument CreateCatalog(IReadOnlyList<LauncherConfig> games)
{
var entries = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
foreach (var game in games)
{
entries[game.GameId] = new CatalogGame
{
Description =
$"{game.GameName} est un jeu de survie à la première personne. Explorez une école abandonnée, " +
"rétablissez le courant et trouvez une issue avant la nuit.\n\n" +
"Chaque salle cache une énigme : fouillez, combinez les objets trouvés et méfiez-vous du bruit.",
Links = new List<CatalogLink>
{
new() { Kind = CatalogLinkKinds.Discord, Url = "https://discord.gg/exemple" },
new() { Kind = CatalogLinkKinds.YouTube, Url = "https://www.youtube.com/@exemple" },
new() { Kind = CatalogLinkKinds.Website, Label = "Site officiel", Url = "https://jeux.exemple.test" }
}
};
}
return new CatalogDocument
{
Revision = 12,
UpdatedAt = Today.AddDays(-3),
Games = entries
};
}
// ------------------------------------------------------------------ Version à publier (menu d'administration)
/// <summary>
/// Dossier de publication complet, comme celui produit par le .bat (manifest.json, archive, objects/),
/// avec de petits fichiers factices. Renvoie le chemin du manifest.
/// </summary>
public static string CreateRelease(string folder, string gameId, string executable)
{
Directory.CreateDirectory(folder);
var random = new Random(20261106);
var files = new List<object>();
void AddFile(string path, int size)
{
var data = new byte[size];
random.NextBytes(data);
var sha = Hex(SHA256.HashData(data));
var objectPath = Path.Combine(folder, "objects", sha[..2], sha + ".bin");
Directory.CreateDirectory(Path.GetDirectoryName(objectPath)!);
File.WriteAllBytes(objectPath, data);
files.Add(new { path, size = (long)size, sha256 = sha });
}
AddFile(executable.Replace('\\', '/'), 96 * 1024);
AddFile("UnityPlayer.dll", 64 * 1024);
AddFile("Exovival_Data/level0", 48 * 1024);
AddFile("Exovival_Data/level1", 40 * 1024);
AddFile("Exovival_Data/sharedassets0.assets", 72 * 1024);
var archiveName = $"{gameId}-1.5.0.zip";
var archive = new byte[160 * 1024];
random.NextBytes(archive);
File.WriteAllBytes(Path.Combine(folder, archiveName), archive);
var manifest = new
{
formatVersion = 1,
gameId,
version = "1.5.0",
releaseDate = Today,
executable = executable.Replace('\\', '/'),
archive = new { url = archiveName, size = (long)archive.Length, sha256 = Hex(SHA256.HashData(archive)) },
objectsBaseUrl = "objects/",
files,
changelog = new[]
{
new
{
version = "1.5.0",
date = Today,
title = "Niveau 2 « Le gymnase »",
changes = new[] { "Nouveau niveau : le gymnase", "Deux nouvelles énigmes", "Corrections diverses" }
}
}
};
var path = Path.Combine(folder, "manifest.json");
File.WriteAllText(path, JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }), new UTF8Encoding(false));
return path;
}
public static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();
/// <summary>Empreinte SHA-256 stable d'un texte (condensés et empreintes de démonstration).</summary>
public static string Digest(string seed) => Hex(SHA256.HashData(Encoding.UTF8.GetBytes(seed)));
}
+163
View File
@@ -0,0 +1,163 @@
using System.IO;
using System.Windows.Media;
using GameLauncher;
using GameLauncher.Core.Catalog;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Services;
using GameLauncher.Services;
using GameLauncher.ViewModels;
using UiSnapshot.Fakes;
namespace UiSnapshot;
/// <summary>
/// Données de démonstration communes aux scènes : configuration réelle du launcher (launcher.json intégré
/// à l'exe, et l'exemple multi-jeux de la solution), dossiers temporaires, services simulés.
/// Rien n'est écrit en dehors du dossier temporaire (supprimé à la fin) ni lu dans le registre.
/// </summary>
internal sealed class Fixture : IDisposable
{
public Fixture(string? repositoryRoot)
{
RepositoryRoot = repositoryRoot;
WorkDirectory = Path.Combine(Path.GetTempPath(), "GameLauncher-UiSnapshot", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(WorkDirectory);
InstallDirectory = Directory.CreateDirectory(Path.Combine(WorkDirectory, "Jeux", "Exovival Zombie Reborn")).FullName;
KeysDirectory = Directory.CreateDirectory(Path.Combine(WorkDirectory, "launcher-keys")).FullName;
var version = typeof(App).Assembly.GetName().Version ?? new Version(1, 0, 0);
CurrentVersion = version;
LauncherVersion = VersionUtil.Display(version);
Updates = new FakeLauncherUpdates(LauncherVersion);
Http = new HttpDownloader($"UiSnapshot/{LauncherVersion}"); // jamais utilisé pour télécharger (aucune opération réelle)
Catalog = LoadEmbeddedCatalog();
Isolate(Catalog, "principal");
CatalogMerger.Apply(Catalog, SampleData.CreateCatalog(Catalog.DeclaredGames), ThemeManager.BuiltInThemes.ToList(),
_ => null, _ => true);
MultiCatalog = LoadMultiGameExample();
}
public string? RepositoryRoot { get; }
public string WorkDirectory { get; }
public string InstallDirectory { get; }
public string KeysDirectory { get; }
public Version CurrentVersion { get; }
public string LauncherVersion { get; }
public FakeLauncherUpdates Updates { get; }
public HttpDownloader Http { get; }
/// <summary>launcher.json intégré à GameLauncher.exe (catalogue de démonstration appliqué : description, liens).</summary>
public LauncherCatalog Catalog { get; }
/// <summary>src\GameLauncher\launcher.multi-jeux.example.json, ou null s'il est introuvable.</summary>
public LauncherCatalog? MultiCatalog { get; }
/// <summary>Remarques sur la préparation (exemple multi-jeux absent…), reprises dans le rapport.</summary>
public List<string> Notes { get; } = new();
public LauncherConfig MainGame => Catalog.Find(Catalog.DefaultGameId) ?? Catalog.Games[0];
/// <summary>Thème du jeu affiché par défaut (thème « naturel » des écrans hors fenêtre principale).</summary>
public string MainTheme => string.IsNullOrWhiteSpace(MainGame.Theme) ? ThemeManager.DefaultTheme : MainGame.Theme;
/// <summary>Applique le thème demandé, ou celui du jeu principal.</summary>
public void ApplyTheme(string? theme) => ThemeManager.Apply(theme ?? MainTheme, theme is null ? MainGame.Background : null);
// ------------------------------------------------------------------ Fenêtre principale
/// <summary>ViewModel de la fenêtre principale avec un GameViewModel par jeu (services simulés).</summary>
public LauncherViewModel CreateLauncher(LauncherCatalog catalog, RecordingDialogs dialogs)
{
var window = new FakeWindowService();
var preferences = new LauncherPreferences();
var launcher = new LauncherViewModel(catalog, window, dialogs, preferences, LauncherVersion, Updates);
foreach (var config in catalog.Games)
{
var settingsPath = Path.Combine(catalog.DataDirectory, "Games", config.GameId, "settings.json");
var manifestUri = Uri.TryCreate(config.ManifestUrl, UriKind.Absolute, out var uri) ? uri : new Uri("https://jeux.exemple.test/manifest.json");
var theme = string.IsNullOrWhiteSpace(config.Theme) ? ThemeManager.DefaultTheme : config.Theme;
var game = new GameViewModel(config, new LauncherSettings(), settingsPath, preferences, new GameInstaller(Http),
new ManifestClient(Http, manifestUri), new FakeRegistry(), new FakeShortcuts(), dialogs, window, LauncherVersion,
Updates, catalog.DataDirectory)
{
IconImage = ThemeManager.LoadImage(config.Icon, 160),
TileImage = ThemeManager.Peek(theme, "BackgroundImage", config.Background) as ImageSource,
TileAccentBrush = ThemeManager.Peek(theme, "AccentGradientBrush") as Brush,
TileForegroundBrush = ThemeManager.Peek(theme, "OnAccentBrush") as Brush
};
launcher.AddGame(game);
}
launcher.SelectedGame = launcher.Find(catalog.DefaultGameId) ?? launcher.Games[0]; // applique le thème du jeu
return launcher;
}
// ------------------------------------------------------------------ Configuration
private static LauncherCatalog LoadEmbeddedCatalog()
{
using var stream = typeof(App).Assembly.GetManifestResourceStream("launcher.json")
?? throw new FileNotFoundException("launcher.json n'est pas intégré à GameLauncher (ressource « launcher.json »).");
return LauncherCatalog.Load(stream);
}
private LauncherCatalog? LoadMultiGameExample()
{
var path = RepositoryRoot is null ? null : Path.Combine(RepositoryRoot, "src", "GameLauncher", "launcher.multi-jeux.example.json");
if (path is null || !File.Exists(path))
{
Notes.Add("Exemple multi-jeux introuvable (src\\GameLauncher\\launcher.multi-jeux.example.json) : scène « main-multi » ignorée.");
return null;
}
try
{
var catalog = LauncherCatalog.Load(path);
Isolate(catalog, "multi");
return catalog;
}
catch (Exception ex)
{
Notes.Add($"Exemple multi-jeux illisible ({ex.Message}) : scène « main-multi » ignorée.");
return null;
}
}
/// <summary>
/// Données du launcher (dernier jeu affiché, paramètres) dans le dossier temporaire : jamais dans
/// %AppData% (le vrai launcher de ce PC n'est pas touché). Refus net si ce n'est plus possible.
/// </summary>
private void Isolate(LauncherCatalog catalog, string name)
{
var directory = Path.Combine(WorkDirectory, "data-" + name);
Directory.CreateDirectory(directory);
Poke.Set(catalog, "_dataDirectory", directory);
if (!string.Equals(catalog.DataDirectory, directory, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("Impossible d'isoler les données du launcher dans un dossier temporaire : outil à mettre à jour.");
}
/// <summary>Racine de la solution (dossier contenant GameLauncher.sln), en remontant depuis l'exécutable.</summary>
public static string? FindRepositoryRoot()
{
foreach (var start in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })
{
var directory = new DirectoryInfo(start);
for (var i = 0; directory is not null && i < 10; i++, directory = directory.Parent)
if (File.Exists(Path.Combine(directory.FullName, "GameLauncher.sln"))) return directory.FullName;
}
return null;
}
public void Dispose()
{
Http.Dispose();
try
{
Directory.Delete(WorkDirectory, recursive: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// fichier encore ouvert : le dossier temporaire sera nettoyé par Windows
}
}
}
+68
View File
@@ -0,0 +1,68 @@
# UiSnapshot — captures de toutes les fenêtres du launcher
Outil de développement : il rend **chaque écran du launcher en PNG**, avec des données de démonstration,
et signale les problèmes de mise en page. Il sert à vérifier un réglage graphique (WPF) sans cliquer
partout, et à montrer l'interface à quelqu'un qui ne peut pas lancer le launcher (Claude, par exemple).
```
tools\ui-snapshot.bat toutes les scènes, thème de chaque jeu
tools\ui-snapshot.bat --filter "main-*" seulement la fenêtre principale
tools\ui-snapshot.bat --filter "admin-page-*" --theme all --scale 2
tools\ui-snapshot.bat --list liste des scènes
tools\ui-snapshot.bat --help toutes les options
```
Dans Visual Studio : projet **UiSnapshot** (dossier `tools`) › Définir comme projet de démarrage, et
arguments éventuels dans Propriétés › Déboguer.
## Résultat (`artifacts\ui-snapshots`, ignoré par git)
| Fichier | Contenu |
|---|---|
| `<scène>.png` | La fenêtre telle qu'affichée (cadre, coins arrondis, ombre) sur un fond neutre |
| `<scène>.p2.png`… | Pages suivantes quand une zone défile (jusqu'à 4) |
| `report.md` | **À lire en premier** : texte coupé ou tronqué, éléments qui dépassent de la fenêtre, images sans source, erreurs de liaison (`{Binding}` vers une propriété inexistante), messages que l'écran a tenté d'afficher, routes de l'API non simulées |
| `index.html` | Planche de toutes les captures (clic : taille réelle) |
| `<scène>.tree.txt` | Avec `--tree` : arbre des éléments (type, `x:Name`, position, texte) pour retrouver un élément dans le XAML |
| `launcher.log` | Journal du launcher pendant les captures |
Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les pixels du PNG).
## Scènes
* **Fenêtre principale** (`main-*`) : non installé, à jour, mise à jour disponible, téléchargement en
cours, préparation, installation introuvable, réparation nécessaire, jeu lancé, hors ligne, mise à jour
bloquée par sécurité, démarrage ; `main-multi` : plusieurs jeux (exemple `launcher.multi-jeux.example.json`).
* **Fenêtres** : paramètres du jeu et du launcher, notes de version agrandies, choix du dossier
d'installation, mise à jour du launcher (téléchargement, version vérifiée, erreur), boîtes de dialogue
(information, succès, question, avertissement, erreur, action irréversible avec option), menu de la zone
de notification.
* **Menu d'administration** (`admin-*`) : connexion (normale, refusée, verrouillée), « J'ai une
invitation » (formulaire, empreinte), puis **chaque page déclarée par les modules** (`admin-page-<id>`,
lues dans `App.CreateModules` : une page ajoutée plus tard est capturée sans modifier l'outil), et une
version prête à publier.
`--theme all` rend chaque scène dans chaque thème intégré (suffixe `@default`, `@exovival`).
## Sécurité et données
* Le launcher n'est **pas démarré** : ses ressources (thèmes, styles) sont chargées, mais aucun code de
démarrage ne s'exécute (instance unique, mise à jour, registre, réseau, raccourcis).
* Les fenêtres sont affichées **hors de l'écran**, sans prendre le focus.
* Données du launcher (dernier jeu affiché…) dans un dossier temporaire supprimé à la fin : `%AppData%` et
le registre ne sont jamais lus ni écrits ; les boîtes de dialogue ne s'ouvrent pas (elles sont notées
dans le rapport) ; le presse-papiers n'est pas touché.
* Menu d'administration : **API simulée en mémoire** (`Fakes\FakeApi.cs`), aucune connexion réseau ; clés
« fichier » et clé racine de démonstration créées dans le dossier temporaire ; aucune clé réelle
(`%USERPROFILE%\.launcher-keys`) n'est lue.
* Projet séparé, jamais distribué aux joueurs : le launcher ne le référence pas.
## Limites
* Les états de survol, d'appui et les menus ou listes déroulantes ouverts (fenêtres surgissantes)
n'apparaissent pas.
* Les états de la fenêtre principale sont posés directement dans `GameViewModel` (membres privés) : si l'un
d'eux est renommé, la scène concernée échoue avec « Membre introuvable » et les autres continuent —
mettre alors à jour `Scenes\PlayerScenes.cs`.
* Un module ajouté plus tard qui appelle une nouvelle route de l'API : sa page affiche une erreur et le
rapport indique la route à simuler dans `Fakes\FakeApi.cs`.
+79
View File
@@ -0,0 +1,79 @@
using System.Reflection;
namespace UiSnapshot;
/// <summary>
/// Accès aux membres non publics, pour placer un ViewModel du launcher dans un état précis (téléchargement
/// en cours, jeu introuvable…) sans exécuter l'opération réelle. Un membre renommé dans le launcher fait
/// échouer la scène concernée avec un message clair (les autres continuent) : l'outil est alors à mettre à jour.
/// </summary>
internal static class Poke
{
private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
private const BindingFlags Static = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
/// <summary>Propriété (même à accesseur privé) ou champ.</summary>
public static void Set(object target, string member, object? value)
{
for (var type = target.GetType(); type is not null; type = type.BaseType)
{
if (type.GetProperty(member, Instance) is { } property && property.GetSetMethod(nonPublic: true) is { } setter)
{
setter.Invoke(target, new[] { value });
return;
}
if (type.GetField(member, Instance) is { } field)
{
field.SetValue(target, value);
return;
}
}
throw Missing(target.GetType(), member);
}
public static object? Get(object target, string member)
{
for (var type = target.GetType(); type is not null; type = type.BaseType)
{
if (type.GetProperty(member, Instance) is { } property && property.GetGetMethod(nonPublic: true) is { } getter)
return getter.Invoke(target, null);
if (type.GetField(member, Instance) is { } field)
return field.GetValue(target);
}
throw Missing(target.GetType(), member);
}
/// <summary>Méthode d'instance (même privée), choisie par son nom et son nombre de paramètres.</summary>
public static object? Call(object target, string method, params object?[] args)
{
for (var type = target.GetType(); type is not null; type = type.BaseType)
{
var found = type.GetMethods(Instance).FirstOrDefault(m => m.Name == method && m.GetParameters().Length == args.Length);
if (found is not null) return Invoke(found, target, args);
}
throw Missing(target.GetType(), $"{method}({args.Length} paramètre(s))");
}
/// <summary>Méthode statique (même privée), choisie par son nom et son nombre de paramètres.</summary>
public static object? CallStatic(Type type, string method, params object?[] args)
{
var found = type.GetMethods(Static).FirstOrDefault(m => m.Name == method && m.GetParameters().Length == args.Length)
?? throw Missing(type, $"{method}({args.Length} paramètre(s))");
return Invoke(found, null, args);
}
private static object? Invoke(MethodInfo method, object? target, object?[] args)
{
try
{
return method.Invoke(target, args);
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
throw ex.InnerException; // l'erreur réelle, pas son enveloppe
}
}
private static MissingMemberException Missing(Type type, string member) =>
new($"Membre introuvable : {type.Name}.{member}. Le launcher a changé : mettez à jour tools\\UiSnapshot (scène ignorée).");
}
+93
View File
@@ -0,0 +1,93 @@
using System.Reflection;
using System.Text;
using System.Windows;
using System.Windows.Threading;
namespace UiSnapshot;
/// <summary>
/// Point d'entrée : l'application WPF du launcher (<see cref="GameLauncher.App"/>) est créée pour ses
/// ressources (thèmes, styles, modèles), SANS être démarrée : aucun code de démarrage du launcher ne
/// s'exécute (ni instance unique, ni mise à jour, ni registre, ni réseau). Les écrans sont ensuite
/// construits un par un avec des données de démonstration, rendus en PNG, puis refermés.
/// </summary>
public static class Program
{
[STAThread]
public static int Main(string[] args)
{
Console.OutputEncoding = Encoding.UTF8;
SnapshotOptions options;
try
{
options = SnapshotOptions.Parse(args);
}
catch (ArgumentException ex)
{
Console.Error.WriteLine(ex.Message);
Console.Error.WriteLine();
Console.Error.WriteLine(SnapshotOptions.Usage);
return 2;
}
if (options.Help)
{
Console.WriteLine(SnapshotOptions.Usage);
return 0;
}
PointResourcesAtLauncher();
var app = new GameLauncher.App();
app.InitializeComponent(); // App.xaml : dictionnaires du thème, des contrôles, des notes de version et de l'administration
app.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var exitCode = 1;
var frame = new DispatcherFrame();
app.Dispatcher.InvokeAsync(async () =>
{
try
{
exitCode = await new SnapshotRunner(options, app).RunAsync();
}
catch (Exception ex)
{
Console.Error.WriteLine($"Erreur : {ex}");
exitCode = 1;
}
finally
{
frame.Continue = false;
}
});
Dispatcher.PushFrame(frame);
// Fin immédiate : aucune fenêtre ne reste ouverte, les déconnexions simulées sont sans objet.
Environment.Exit(exitCode);
return exitCode;
}
/// <summary>
/// Les adresses « /Assets/… » (sans nom d'assembly) des fichiers XAML désignent l'assembly de
/// l'application, qui est ici UiSnapshot : elles sont redirigées vers celui du launcher. Sans effet
/// sur les adresses complètes (« pack://application:,,,/GameLauncher;component/… »), déjà utilisées
/// par le launcher. Au mieux : un échec est signalé, l'outil continue.
/// </summary>
private static void PointResourcesAtLauncher()
{
var launcher = typeof(GameLauncher.App).Assembly;
try
{
typeof(Application).GetField("_resourceAssembly", BindingFlags.NonPublic | BindingFlags.Static)?.SetValue(null, launcher);
foreach (var assembly in new[] { typeof(UIElement).Assembly, typeof(Window).Assembly })
{
assembly.GetType("System.Windows.Navigation.BaseUriHelper")?
.GetProperty("ResourceAssembly", BindingFlags.NonPublic | BindingFlags.Static)?
.SetValue(null, launcher);
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Avertissement : ressources « /Assets/… » non redirigées vers le launcher ({ex.Message}).");
}
}
}
+98
View File
@@ -0,0 +1,98 @@
using System.IO;
using System.Net;
using System.Text;
namespace UiSnapshot;
/// <summary>report.md (problèmes détectés, à lire en premier) et index.html (planche de toutes les captures).</summary>
internal static class Report
{
public static void Write(Capture capture, SnapshotOptions options, TimeSpan duration)
{
File.WriteAllText(Path.Combine(capture.OutputDirectory, "report.md"), Markdown(capture, options, duration), new UTF8Encoding(false));
File.WriteAllText(Path.Combine(capture.OutputDirectory, "index.html"), Html(capture, options), new UTF8Encoding(false));
}
private static string Markdown(Capture capture, SnapshotOptions options, TimeSpan duration)
{
var sb = new StringBuilder();
sb.AppendLine($"# Captures de l'interface — {DateTime.Now:dd/MM/yyyy HH:mm}");
sb.AppendLine();
sb.AppendLine($"Échelle {options.Scale:0.##} · thème {(options.Theme ?? "du jeu")} · attente {options.WaitMilliseconds} ms · " +
$"{(options.Filters.Count == 0 ? "toutes les scènes" : "filtre " + string.Join(", ", options.Filters))} · " +
$"{duration.TotalSeconds:0} s");
sb.AppendLine("Positions en unités WPF (× échelle pour les pixels du PNG). Données de démonstration, aucun serveur réel.");
sb.AppendLine();
sb.AppendLine("| Scène | Image | Taille | Problèmes | Remarques |");
sb.AppendLine("|---|---|---|---|---|");
foreach (var r in capture.Records)
{
var status = r.Error is not null ? "**ÉCHEC**" : r.Skipped is not null ? "ignorée" : (r.Issues.Count + r.Bindings.Count).ToString();
sb.AppendLine($"| `{r.Id}` | {string.Join(" ", r.Files.Where(f => f.EndsWith(".png", StringComparison.Ordinal)))} | {r.Size} | {status} | {r.Notes.Count} |");
}
sb.AppendLine();
if (capture.GlobalNotes.Count > 0)
{
sb.AppendLine("## Remarques générales");
foreach (var note in capture.GlobalNotes) sb.AppendLine($"- {note}");
sb.AppendLine();
}
foreach (var r in capture.Records)
{
if (r.Skipped is null && !r.HasProblems && r.Notes.Count == 0) continue;
sb.AppendLine($"## {r.Id} — {r.Title}");
if (r.Skipped is not null) sb.AppendLine($"- Ignorée : {r.Skipped}");
if (r.Error is not null) sb.AppendLine($"- **Échec** : {r.Error}");
foreach (var issue in r.Issues) sb.AppendLine($"- ⚠ {issue}");
foreach (var binding in r.Bindings) sb.AppendLine($"- ⚠ Liaison : {binding}");
foreach (var note in r.Notes) sb.AppendLine($"- {note}");
sb.AppendLine();
}
return sb.ToString();
}
private static string Html(Capture capture, SnapshotOptions options)
{
static string E(string? text) => WebUtility.HtmlEncode(text ?? "");
var sb = new StringBuilder();
sb.AppendLine("<!doctype html><html lang=\"fr\"><head><meta charset=\"utf-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Captures du launcher</title>");
sb.AppendLine("<style>");
sb.AppendLine(":root{color-scheme:dark;--bg:#111317;--card:#1b1e24;--line:#2c313a;--text:#e6e8eb;--muted:#9aa1ab;--warn:#f0b429;--bad:#ef5350}");
sb.AppendLine("body{margin:0;padding:24px;background:var(--bg);color:var(--text);font:14px/1.45 'Segoe UI',system-ui,sans-serif}");
sb.AppendLine("h1{font-size:20px;margin:0 0 4px}p.sub{color:var(--muted);margin:0 0 20px}");
sb.AppendLine(".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:18px}");
sb.AppendLine(".card{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden}");
sb.AppendLine(".card img{display:block;width:100%;height:auto;background:#000}");
sb.AppendLine(".meta{padding:10px 12px}.id{font-family:Consolas,monospace;font-size:13px}.title{color:var(--muted)}");
sb.AppendLine(".pages a{color:var(--muted);margin-right:8px;font-size:12px}");
sb.AppendLine("ul{margin:6px 0 0;padding-left:18px;font-size:12px}li.warn{color:var(--warn)}li.bad{color:var(--bad)}li.note{color:var(--muted)}");
sb.AppendLine("</style></head><body>");
sb.AppendLine($"<h1>Captures du launcher</h1><p class=\"sub\">{DateTime.Now:dd/MM/yyyy HH:mm} · échelle {options.Scale:0.##} · " +
$"{capture.Records.Count} scène(s) · données de démonstration · détails dans report.md</p>");
sb.AppendLine("<div class=\"grid\">");
foreach (var r in capture.Records)
{
var pngs = r.Files.Where(f => f.EndsWith(".png", StringComparison.Ordinal)).ToList();
sb.AppendLine("<div class=\"card\">");
if (pngs.Count > 0) sb.AppendLine($"<a href=\"{E(pngs[0])}\"><img loading=\"lazy\" src=\"{E(pngs[0])}\" alt=\"{E(r.Title)}\"></a>");
sb.AppendLine($"<div class=\"meta\"><div class=\"id\">{E(r.Id)}</div><div class=\"title\">{E(r.Title)} {E(r.Size)}</div>");
if (pngs.Count > 1)
sb.AppendLine("<div class=\"pages\">" + string.Join("", pngs.Skip(1).Select((p, i) => $"<a href=\"{E(p)}\">page {i + 2}</a>")) + "</div>");
var items = new List<string>();
if (r.Skipped is not null) items.Add($"<li class=\"note\">Ignorée : {E(r.Skipped)}</li>");
if (r.Error is not null) items.Add($"<li class=\"bad\">Échec : {E(r.Error)}</li>");
items.AddRange(r.Issues.Select(i => $"<li class=\"warn\">{E(i)}</li>"));
items.AddRange(r.Bindings.Select(b => $"<li class=\"warn\">Liaison : {E(b)}</li>"));
items.AddRange(r.Notes.Select(n => $"<li class=\"note\">{E(n)}</li>"));
if (items.Count > 0) sb.AppendLine("<ul>" + string.Join("", items) + "</ul>");
sb.AppendLine("</div></div>");
}
sb.AppendLine("</div></body></html>");
return sb.ToString();
}
}
+264
View File
@@ -0,0 +1,264 @@
using System.IO;
using System.Reflection;
using System.Security.Cryptography;
using System.Windows;
using GameLauncher;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Platform;
using GameLauncher.Services;
using GameLauncher.Views.Admin;
using UiSnapshot.Fakes;
namespace UiSnapshot.Scenes;
/// <summary>
/// Menu d'administration, avec la vraie fenêtre et les vrais ViewModels, reliés à une Launcher API
/// simulée en mémoire (<see cref="FakeApi"/>) : connexion, « J'ai une invitation », puis chaque page
/// déclarée par les modules (les pages des modules ajoutés plus tard sont capturées automatiquement).
/// La connexion utilise une vraie clé « fichier » temporaire (phrase de passe connue de l'outil seulement).
/// </summary>
internal sealed class AdminScenes : ISceneSet
{
private const string Passphrase = "UiSnapshot : phrase de passe de démonstration";
private const string PagePrefix = "admin-page-";
private readonly Fixture _fixture;
public AdminScenes(Fixture fixture) => _fixture = fixture;
private static readonly (string Id, string Title)[] FixedScenes =
{
("admin-login", "Administration · connexion"),
("admin-login-error", "Administration · connexion refusée"),
("admin-enroll", "Administration · « J'ai une invitation »"),
("admin-enroll-done", "Administration · clé créée, empreinte à communiquer"),
(PagePrefix + "changelog-notes-game", "Administration · notes de version (jeu choisi)"),
(PagePrefix + "releases-publish-ready", "Administration · version prête à publier"),
("admin-login-locked", "Administration · session verrouillée")
};
public IEnumerable<SceneInfo> List(IReadOnlyList<string?> themes)
{
var pages = CreateModules().AdminPanels.Select(p => (Id: PagePrefix + Slug(p.Id), p.Title)).ToList();
foreach (var theme in themes)
{
var suffix = Capture.Suffix(theme);
foreach (var (id, title) in FixedScenes.Take(4)) yield return new SceneInfo(id + suffix, title);
foreach (var (id, title) in pages) yield return new SceneInfo(id + suffix, "Administration · " + title);
foreach (var (id, title) in FixedScenes.Skip(4)) yield return new SceneInfo(id + suffix, title);
}
}
public async Task RunAsync(Capture capture)
{
var modules = CreateModules();
var all = List(capture.Themes).Select(s => s.Id).ToList();
if (!capture.WantsAny(all)) return;
// Clé « fichier » de démonstration (comme tools\admin-key.bat) et une seconde clé pour la liste.
var keyPath = Path.Combine(_fixture.KeysDirectory, "nicolas-pc-bureau.key");
byte[] ownerPublicKey;
using (var key = AdminKeyFile.Generate())
{
AdminKeyFile.Save(key, keyPath, Passphrase);
ownerPublicKey = key.ExportSubjectPublicKeyInfo();
}
using (var other = AdminKeyFile.Generate())
AdminKeyFile.Save(other, Path.Combine(_fixture.KeysDirectory, "nicolas-portable.key"), Passphrase);
using var root = ECDsa.Create(ECCurve.NamedCurves.nistP256); // clé racine de démonstration (trousseau)
var rootPublicKey = root.ExportSubjectPublicKeyInfo();
var api = new FakeApi(new SampleWorld(ownerPublicKey, _fixture.Catalog.DeclaredGames, root));
using var client = new LauncherApiClient(new Uri(FakeApi.BaseUrl), $"GameLauncher/{_fixture.LauncherVersion}", api);
var dialogs = new RecordingDialogs(capture.Problem);
var shell = new AdminShellViewModel(new AdminEnvironment
{
Client = client,
Modules = modules,
Games = _fixture.Catalog.DeclaredGames,
LauncherName = _fixture.Catalog.DeclaredLauncherName,
DefaultGameId = _fixture.Catalog.DeclaredDefaultGameId,
Themes = ThemeManager.BuiltInThemes.ToList(),
Dialogs = dialogs,
Ui = new WpfUiDispatcher(Application.Current.Dispatcher),
Tpm = new FakeTpm(),
SettingsPath = Path.Combine(_fixture.WorkDirectory, "admin.json"),
KeysDirectory = _fixture.KeysDirectory,
LauncherVersion = _fixture.LauncherVersion,
RootKeys = new[] { (AdminKeys.ComputeKeyId(rootPublicKey), rootPublicKey) },
StartTimers = false // pas de verrouillage ni de prolongation automatiques pendant les captures
});
_fixture.ApplyTheme(capture.Themes[0]);
var window = new AdminWindow(shell);
Capture.ShowOffscreen(window);
try
{
await RunScenesAsync();
}
finally
{
window.Close(); // déconnexion simulée, clé effacée de la mémoire
if (api.Unhandled.Count > 0)
capture.GlobalNotes.Add("⚠ API simulée : routes demandées mais non simulées — " + string.Join(", ", api.Unhandled) +
" (à ajouter dans tools\\UiSnapshot\\Fakes\\FakeApi.cs).");
}
// La fenêtre réutilisée pour toutes les scènes (rendue dans chaque thème demandé).
async Task ShootAllThemesAsync(string id, string title)
{
foreach (var theme in capture.Themes)
await capture.ShootAsync(id + Capture.Suffix(theme), title, () =>
{
_fixture.ApplyTheme(theme);
return Task.FromResult<Window>(window);
});
}
async Task RunScenesAsync()
{
// ---- Connexion
var login = shell.Screen as LoginViewModel ?? throw new InvalidOperationException("Écran de connexion attendu.");
SelectKey(login, keyPath);
await ShootAllThemesAsync("admin-login", "Administration · connexion");
Poke.Set(login, "ErrorMessage",
"Connexion refusée : cette clé n'est pas reconnue par le serveur, est encore en attente d'approbation, ou a été révoquée.");
await ShootAllThemesAsync("admin-login-error", "Administration · connexion refusée");
Poke.Set(login, "ErrorMessage", null);
// ---- « J'ai une invitation »
if (capture.WantsAny(new[] { "admin-enroll", "admin-enroll-done" }.SelectMany(id => capture.Themes.Select(t => id + Capture.Suffix(t)))))
{
shell.ShowEnroll();
var enroll = shell.Screen as EnrollViewModel ?? throw new InvalidOperationException("Écran « J'ai une invitation » attendu.");
enroll.KeyLabel = "PC-BUREAU";
await ShootAllThemesAsync("admin-enroll", "Administration · « J'ai une invitation »");
enroll.InvitationCode = InvitationCodes.Generate();
await enroll.SubmitAsync(Passphrase, Passphrase); // crée une clé dans le dossier temporaire, enregistrée par l'API simulée
if (enroll.HasError) capture.GlobalNotes.Add($"« J'ai une invitation » : {enroll.ErrorMessage}");
await ShootAllThemesAsync("admin-enroll-done", "Administration · clé créée, empreinte à communiquer");
shell.ShowLogin();
}
// ---- Espace d'administration
var pages = all.Where(id => id.StartsWith(PagePrefix, StringComparison.Ordinal) || id.StartsWith("admin-login-locked", StringComparison.Ordinal));
if (!capture.WantsAny(pages)) return;
login = shell.Screen as LoginViewModel ?? throw new InvalidOperationException("Écran de connexion attendu.");
SelectKey(login, keyPath);
await login.LoginAsync(Passphrase);
if (shell.Screen is not AdminWorkspaceViewModel workspace)
throw new InvalidOperationException($"Connexion simulée impossible : {login.ErrorMessage ?? "écran inattendu"}.");
foreach (var item in workspace.PanelItems.ToList())
{
var id = PagePrefix + Slug(item.Descriptor.Id);
if (!capture.WantsAny(capture.Themes.Select(t => id + Capture.Suffix(t)))) continue;
workspace.SelectedPanel = item;
await WaitForPanelAsync(capture, item, api);
await ShootAllThemesAsync(id, "Administration · " + item.Title);
}
// ---- Notes de version, jeu choisi (plusieurs jeux : la page s'ouvre sur le choix du jeu)
var changelogId = PagePrefix + "changelog-notes-game";
if (capture.WantsAny(capture.Themes.Select(t => changelogId + Capture.Suffix(t))) &&
workspace.PanelItems.FirstOrDefault(p => p.Descriptor.Id == ChangelogPanelViewModel.PanelId) is { } changelogItem)
{
workspace.SelectedPanel = changelogItem;
await WaitForPanelAsync(capture, changelogItem, api);
if (changelogItem.ViewModel is ChangelogPanelViewModel { SelectedGame: null } changelog && MainChoice(changelog.GameChoices) is { } choice)
{
changelog.SelectedGame = choice;
await WaitForPanelAsync(capture, changelogItem, api);
}
await ShootAllThemesAsync(changelogId, "Administration · notes de version (jeu choisi)");
}
// ---- Version prête à publier (dossier de publication factice)
var publishId = PagePrefix + "releases-publish-ready";
if (capture.WantsAny(capture.Themes.Select(t => publishId + Capture.Suffix(t))) &&
workspace.PanelItems.FirstOrDefault(p => p.Descriptor.Id == PublishPanelViewModel.PanelId) is { } publishItem)
{
workspace.SelectedPanel = publishItem;
await WaitForPanelAsync(capture, publishItem, api);
if (publishItem.ViewModel is PublishPanelViewModel publish)
{
if (publish.SelectedGame is null && MainChoice(publish.GameChoices) is { } choice)
{
publish.SelectedGame = choice; // plusieurs jeux : choisi comme le ferait l'administrateur
await WaitForPanelAsync(capture, publishItem, api);
}
var gameId = publish.SelectedGame?.Value ?? _fixture.MainGame.GameId;
var manifest = SampleData.CreateRelease(Path.Combine(_fixture.WorkDirectory, "publication"), gameId,
_fixture.Catalog.Find(gameId)?.GameExecutable ?? "Jeu.exe");
await publish.InspectAsync(manifest);
await WaitForPanelAsync(capture, publishItem, api);
}
await ShootAllThemesAsync(publishId, "Administration · version prête à publier");
}
// ---- Verrouillage
await shell.LockAsync("Session verrouillée après 10 minutes sans activité.");
await ShootAllThemesAsync("admin-login-locked", "Administration · session verrouillée");
}
}
/// <summary>Jeu principal de la démonstration parmi les choix d'une page (sinon le premier).</summary>
private Choice? MainChoice(IReadOnlyList<Choice> choices) =>
choices.FirstOrDefault(c => string.Equals(c.Value, _fixture.MainGame.GameId, StringComparison.OrdinalIgnoreCase)) ?? choices.FirstOrDefault();
private static void SelectKey(LoginViewModel login, string keyPath) =>
login.SelectedKey = login.Keys.FirstOrDefault(k => string.Equals(Path.GetFullPath(k.Path), Path.GetFullPath(keyPath), StringComparison.OrdinalIgnoreCase))
?? login.Keys.FirstOrDefault();
/// <summary>Attend la fin du chargement d'une page (page non occupée, aucune requête en cours), 15 s au plus.</summary>
private static async Task WaitForPanelAsync(Capture capture, AdminPanelItem item, FakeApi api)
{
var deadline = DateTime.UtcNow.AddSeconds(15);
var calm = 0;
while (DateTime.UtcNow < deadline)
{
await Task.Delay(60);
var busy = item.ViewModel is null || item.ViewModel.IsBusy || api.InFlight > 0;
calm = busy ? 0 : calm + 1;
if (calm >= 3) return;
}
capture.GlobalNotes.Add($"⚠ Page « {item.Title} » encore en chargement après 15 s (capturée telle quelle).");
}
/// <summary>Modules réellement déclarés par le launcher (App.CreateModules) : les pages ajoutées plus tard sont incluses.</summary>
private static ModuleCatalog CreateModules()
{
try
{
if (typeof(App).GetMethod("CreateModules", BindingFlags.NonPublic | BindingFlags.Static)?.Invoke(null, null) is ModuleCatalog modules)
return modules;
}
catch (TargetInvocationException)
{
// liste de secours ci-dessous
}
return new ModuleCatalog(new ILauncherModule[]
{
new AdminModule(), new CatalogAdminModule(), new ChangelogAdminModule(), new PublishAdminModule(),
new AccountsAdminModule(), new SigningAdminModule(), new AuditAdminModule()
});
}
/// <summary>« catalog.games » → « catalog-games ».</summary>
private static string Slug(string id) =>
new(id.ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
}
+334
View File
@@ -0,0 +1,334 @@
using System.Windows;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
using GameLauncher.Services;
using GameLauncher.ViewModels;
using GameLauncher.Views;
using UiSnapshot.Fakes;
namespace UiSnapshot.Scenes;
/// <summary>
/// Écrans du joueur : fenêtre principale dans chacun de ses états, liste des jeux, paramètres, notes de
/// version, installation, mise à jour du launcher, boîtes de dialogue, menu de la zone de notification.
/// Les états sont posés directement dans le ViewModel réel (aucune opération réelle n'est lancée).
/// </summary>
internal sealed class PlayerScenes : ISceneSet
{
private const long DownloadSize = 3_180_000_000;
private const long InstalledSize = 8_420_000_000;
private readonly Fixture _fixture;
public PlayerScenes(Fixture fixture) => _fixture = fixture;
private sealed record MainState(string Id, string Title, Action<GameViewModel, Fixture> Apply);
/// <summary>États de la fenêtre principale (textes repris de GameViewModel).</summary>
private static readonly MainState[] MainStates =
{
new("main-ready", "Fenêtre principale · installé et à jour", (g, f) =>
{
Installed(g, f, "1.4.2", "1.4.2");
State(g, LauncherState.Ready, "Prêt à jouer", "Version 1.4.2");
}),
new("main-not-installed", "Fenêtre principale · non installé", (g, f) =>
{
Poke.Set(g, "LatestVersion", "1.4.2");
Notes(g);
State(g, LauncherState.NotInstalled, "Prêt à installer",
$"Téléchargement : {Format.Bytes(DownloadSize)} • Espace requis : {Format.Bytes(InstalledSize)}");
}),
new("main-update", "Fenêtre principale · mise à jour disponible", (g, f) =>
{
Installed(g, f, "1.4.1", "1.4.2");
State(g, LauncherState.UpdateAvailable, "Mise à jour disponible : version 1.4.2", "Version installée : 1.4.1");
Banner(g, "Mise à jour disponible", "La version 1.4.2 est disponible. Cliquez sur « Mettre à jour » pour l'installer.", DialogKind.Info);
}),
new("main-busy", "Fenêtre principale · téléchargement en cours", (g, f) =>
{
Installed(g, f, "1.4.1", "1.4.2");
Poke.Set(g, "_operationCancellable", true);
State(g, LauncherState.Busy, "Téléchargement du jeu",
$"{Format.Bytes(1_340_000_000)} / {Format.Bytes(DownloadSize)} • {Format.Speed(24_600_000)} • " +
$"{Format.Duration(TimeSpan.FromSeconds(75))} restantes");
Poke.Set(g, "StepText", "Étape 1/3");
Poke.Set(g, "IsProgressIndeterminate", false);
Poke.Set(g, "ProgressValue", 42.0);
Poke.Set(g, "ShowProgress", true);
}),
new("main-busy-indeterminate", "Fenêtre principale · préparation (progression indéterminée)", (g, f) =>
{
Installed(g, f, "1.4.2", "1.4.2");
Poke.Set(g, "_operationCancellable", true);
State(g, LauncherState.Busy, "Vérification de l'intégrité", "Préparation…");
Poke.Set(g, "IsProgressIndeterminate", true);
Poke.Set(g, "ShowProgress", true);
}),
new("main-broken", "Fenêtre principale · installation introuvable", (g, f) =>
{
Poke.Set(g, "InstalledVersion", "1.4.2");
Poke.Set(g, "LatestVersion", "1.4.2");
Poke.Set(g, "InstallPath", @"D:\Jeux\Exovival Zombie Reborn");
Notes(g);
State(g, LauncherState.Broken, "Installation introuvable", "Les fichiers du jeu ont été déplacés ou supprimés.");
Banner(g, "Le jeu est introuvable",
"Le jeu est enregistré dans « D:\\Jeux\\Exovival Zombie Reborn », mais ses fichiers n'y sont plus. " +
"Vous pouvez le réparer (nouveau téléchargement), indiquer son nouvel emplacement, ou oublier cette installation.",
DialogKind.Warning);
}),
new("main-repair", "Fenêtre principale · fichiers endommagés", (g, f) =>
{
Installed(g, f, "1.4.2", "1.4.2");
State(g, LauncherState.NeedsRepair, "Des fichiers sont endommagés", "La réparation ne retélécharge que les fichiers concernés.");
Banner(g, "Réparation nécessaire", "3 fichiers sont manquants ou endommagés (412 Mo).", DialogKind.Warning);
}),
new("main-running", "Fenêtre principale · jeu lancé", (g, f) =>
{
Installed(g, f, "1.4.2", "1.4.2");
State(g, LauncherState.Running, "Jeu en cours d'exécution", "Le launcher se réactivera à la fermeture du jeu.");
}),
new("main-offline", "Fenêtre principale · hors ligne", (g, f) =>
{
Installed(g, f, "1.4.2", null);
Poke.Set(g, "IsOffline", true);
State(g, LauncherState.Ready, "Prêt à jouer (hors ligne)", "Version 1.4.2");
Banner(g, "Mode hors ligne", "Le serveur de mises à jour est injoignable. Vous pouvez tout de même jouer.", DialogKind.Warning);
}),
new("main-blocked", "Fenêtre principale · mise à jour bloquée par sécurité", (g, f) =>
{
Installed(g, f, "1.4.2", null);
State(g, LauncherState.Ready, "Prêt à jouer", "Version 1.4.2");
Banner(g, "Mise à jour bloquée par sécurité",
"Les informations publiées sur le serveur n'ont pas une signature valide : elles sont ignorées. Vous pouvez tout de même jouer.",
DialogKind.Error);
}),
new("main-checking", "Fenêtre principale · démarrage (vérification)", (g, f) => { })
};
public IEnumerable<SceneInfo> List(IReadOnlyList<string?> themes)
{
foreach (var theme in themes)
{
var suffix = Capture.Suffix(theme);
foreach (var state in MainStates) yield return new SceneInfo(state.Id + suffix, state.Title);
yield return new SceneInfo("main-multi" + suffix, "Fenêtre principale · plusieurs jeux (liste à gauche)");
yield return new SceneInfo("settings-game" + suffix, "Paramètres du jeu");
yield return new SceneInfo("settings-launcher" + suffix, "Paramètres du launcher");
yield return new SceneInfo("changelog-window" + suffix, "Notes de version agrandies");
yield return new SceneInfo("install-dialog" + suffix, "Installation : choix du dossier");
yield return new SceneInfo("launcher-update" + suffix, "Mise à jour du launcher · téléchargement");
yield return new SceneInfo("launcher-update-verified" + suffix, "Mise à jour du launcher · version vérifiée");
yield return new SceneInfo("launcher-update-error" + suffix, "Mise à jour du launcher · erreur");
foreach (var (id, title, _) in Dialogs) yield return new SceneInfo(id + suffix, title);
yield return new SceneInfo("tray-menu" + suffix, "Menu de la zone de notification");
}
}
public async Task RunAsync(Capture capture)
{
foreach (var theme in capture.Themes)
{
var suffix = Capture.Suffix(theme);
foreach (var state in MainStates)
await MainAsync(capture, state.Id + suffix, state.Title, _fixture.Catalog, theme, state.Apply);
if (_fixture.MultiCatalog is { } multi)
await MainAsync(capture, "main-multi" + suffix, "Fenêtre principale · plusieurs jeux (liste à gauche)", multi, theme,
(g, f) =>
{
Installed(g, f, "1.4.2", "1.4.2");
State(g, LauncherState.Ready, "Prêt à jouer", "Version 1.4.2");
});
else
capture.Skip("main-multi" + suffix, "Fenêtre principale · plusieurs jeux", "exemple multi-jeux introuvable");
await SettingsAsync(capture, suffix, theme);
await WindowsAsync(capture, suffix, theme);
await DialogsAsync(capture, suffix, theme);
}
}
// ------------------------------------------------------------------ Fenêtre principale
private Task MainAsync(Capture capture, string id, string title, LauncherCatalog catalog, string? theme,
Action<GameViewModel, Fixture> apply) =>
capture.ShootAsync(id, title, () =>
{
var launcher = _fixture.CreateLauncher(catalog, new RecordingDialogs(capture.Problem));
var game = launcher.SelectedGame ?? throw new InvalidOperationException("Aucun jeu dans launcher.json.");
apply(game, _fixture);
Poke.Call(game, "RefreshCommands");
var window = new MainWindow();
if (launcher.IsMultiGame) window.Width += MainWindow.GameRailWidth;
window.DataContext = launcher;
if (theme is not null) ThemeManager.Apply(theme);
return Task.FromResult<Window>(window);
}, window =>
{
if (window.DataContext is LauncherViewModel launcher)
{
launcher.IsExitConfirmed = true; // pas de confirmation « Quitter le launcher ? » (opération en cours)
window.Close();
launcher.Dispose();
}
else
{
window.Close();
}
return Task.CompletedTask;
});
/// <summary>Jeu installé dans le dossier temporaire (le bouton « Dossier du jeu » est actif).</summary>
private static void Installed(GameViewModel game, Fixture fixture, string installed, string? latest)
{
Poke.Set(game, "InstallPath", fixture.InstallDirectory);
Poke.Set(game, "InstalledVersion", installed);
Poke.Set(game, "LatestVersion", latest);
Poke.Set(game, "InstallSizeText", Format.Bytes(InstalledSize));
Notes(game);
}
/// <summary>Notes de version du manifest (affichées dans le panneau de droite).</summary>
private static void Notes(GameViewModel game)
{
foreach (var entry in SampleData.CreateChangelog()) game.Changelog.Add(entry);
Poke.Set(game, "HasChangelog", true);
}
private static void State(GameViewModel game, LauncherState state, string title, string detail)
{
Poke.Set(game, "State", state);
Poke.Set(game, "StatusTitle", title);
Poke.Set(game, "StatusDetail", detail);
}
private static void Banner(GameViewModel game, string title, string message, DialogKind kind) =>
Poke.Call(game, "ShowBanner", title, message, kind, false);
// ------------------------------------------------------------------ Paramètres
private async Task SettingsAsync(Capture capture, string suffix, string? theme)
{
var game = _fixture.MainGame;
await capture.ShootAsync("settings-game" + suffix, "Paramètres du jeu", () =>
{
_fixture.ApplyTheme(theme);
var info = new GameSettingsInfo(game.ShownName, _fixture.InstallDirectory, "1.4.2", "1.4.2", Format.Bytes(InstalledSize),
game.GameLogsDirectory, game.GameDataDirectory);
var shortcut = new GameShortcutActions(() => game.ShownName,
() => new ShortcutActionResult(true, "Raccourci créé sur le Bureau."));
var viewModel = new GameSettingsViewModel(new LauncherSettings(), info, new FakeWindowService(), shortcut);
return Task.FromResult<Window>(new SettingsWindow { DataContext = viewModel });
}, Capture.CloseAsync);
await capture.ShootAsync("settings-launcher" + suffix, "Paramètres du launcher", () =>
{
_fixture.ApplyTheme(theme);
var viewModel = new LauncherSettingsViewModel(new LauncherPreferences(), _fixture.Catalog.LauncherName, _fixture.LauncherVersion,
new FakeWindowService(), _fixture.Updates);
return Task.FromResult<Window>(new SettingsWindow { DataContext = viewModel });
}, Capture.CloseAsync);
}
// ------------------------------------------------------------------ Autres fenêtres
private async Task WindowsAsync(Capture capture, string suffix, string? theme)
{
var game = _fixture.MainGame;
await capture.ShootAsync("changelog-window" + suffix, "Notes de version agrandies", () =>
{
_fixture.ApplyTheme(theme);
return Task.FromResult<Window>(new ChangelogWindow(game.ShownName, SampleData.CreateChangelog()));
}, Capture.CloseAsync);
await capture.ShootAsync("install-dialog" + suffix, "Installation : choix du dossier", () =>
{
_fixture.ApplyTheme(theme);
return Task.FromResult<Window>(new InstallDialog(game.ShownName, game.ExpandedDefaultInstallDirectory, DownloadSize, InstalledSize));
}, Capture.CloseAsync);
var release = SampleData.CreateLauncherRelease(_fixture.CurrentVersion);
var check = new LauncherUpdateCheck(_fixture.CurrentVersion, release, new Uri("https://jeux.exemple.test/launcher/GameLauncher.exe"));
await capture.ShootAsync("launcher-update" + suffix, "Mise à jour du launcher · téléchargement", () =>
{
_fixture.ApplyTheme(theme);
var viewModel = new LauncherUpdateViewModel(check, automatic: true);
viewModel.Progress.Report(new OperationProgress("Téléchargement de la nouvelle version",
$"{Format.Bytes(12_400_000)} / {Format.Bytes(release.Size)} • {Format.Speed(6_300_000)} • " +
$"{Format.Duration(TimeSpan.FromSeconds(4))} restantes", 32.5));
return Task.FromResult<Window>(new LauncherUpdateWindow { DataContext = viewModel });
}, CloseUpdateWindowAsync);
await capture.ShootAsync("launcher-update-verified" + suffix, "Mise à jour du launcher · version vérifiée", () =>
{
_fixture.ApplyTheme(theme);
var viewModel = new LauncherUpdateViewModel(check, automatic: true);
viewModel.SetVerified();
return Task.FromResult<Window>(new LauncherUpdateWindow { DataContext = viewModel });
}, CloseUpdateWindowAsync);
await capture.ShootAsync("launcher-update-error" + suffix, "Mise à jour du launcher · erreur", () =>
{
_fixture.ApplyTheme(theme);
var viewModel = new LauncherUpdateViewModel(check, automatic: false);
viewModel.SetError("La signature de la nouvelle version est invalide : mise à jour refusée. L'ancienne version reste en place.");
return Task.FromResult<Window>(new LauncherUpdateWindow { DataContext = viewModel });
}, CloseUpdateWindowAsync);
await capture.ShootAsync("tray-menu" + suffix, "Menu de la zone de notification", () =>
{
_fixture.ApplyTheme(theme);
return Task.FromResult<Window>(new TrayMenuWindow());
}, Capture.CloseAsync);
}
private static Task CloseUpdateWindowAsync(Window window)
{
if (window is LauncherUpdateWindow update) update.CloseFromService(); // sinon la fenêtre refuse de se fermer
else window.Close();
return Task.CompletedTask;
}
// ------------------------------------------------------------------ Boîtes de dialogue
private static readonly (string Id, string Title, Func<Fixture, (string Title, string Message, DialogKind Kind, string Confirm, string? Cancel, string? Option)> Content)[] Dialogs =
{
("dialog-info", "Boîte de dialogue · information", f =>
("Launcher mis à jour", $"Vous utilisez maintenant la version {f.LauncherVersion} du launcher.", DialogKind.Info, "OK", null, null)),
("dialog-success", "Boîte de dialogue · succès", f =>
("Installation terminée", $"{f.MainGame.ShownName} est installé. Bon jeu !", DialogKind.Success, "OK", null, null)),
("dialog-question", "Boîte de dialogue · question", f =>
("Localiser le jeu", "Un dossier contenant le jeu a été trouvé. Voulez-vous l'utiliser ?", DialogKind.Question, "Utiliser ce dossier", "Annuler", null)),
("dialog-warning", "Boîte de dialogue · avertissement", f =>
("Quitter le launcher ?", "Une opération est en cours et va être interrompue. Les données déjà téléchargées sont conservées.",
DialogKind.Warning, "Quitter", "Rester", null)),
("dialog-error", "Boîte de dialogue · erreur", f =>
("Installation : échec", "Impossible de contacter le serveur de téléchargement.\n\nLa connexion a expiré (délai de 30 secondes).",
DialogKind.Error, "OK", null, null)),
("dialog-danger", "Boîte de dialogue · action irréversible avec option", f =>
($"Désinstaller {f.MainGame.ShownName} ?",
$"Les fichiers du jeu seront supprimés de « {f.MainGame.ExpandedDefaultInstallDirectory} ».",
DialogKind.Danger, "Désinstaller", "Annuler", "Supprimer aussi les sauvegardes et les réglages du jeu"))
};
private async Task DialogsAsync(Capture capture, string suffix, string? theme)
{
foreach (var (id, title, content) in Dialogs)
{
await capture.ShootAsync(id + suffix, title, () =>
{
_fixture.ApplyTheme(theme);
var c = content(_fixture);
// ModernDialog.Create (privée) : la même boîte que ModernDialog.ShowMessage / ShowConfirm, sans l'afficher en modal.
var dialog = (Window)(Poke.CallStatic(typeof(ModernDialog), "Create", null, c.Title, c.Message, c.Kind, c.Confirm, c.Cancel, c.Option)
?? throw new InvalidOperationException("ModernDialog.Create n'a rien renvoyé."));
return Task.FromResult(dialog);
}, Capture.CloseAsync);
}
}
}
+138
View File
@@ -0,0 +1,138 @@
using System.Globalization;
using System.Windows.Media;
namespace UiSnapshot;
/// <summary>Options de la ligne de commande.</summary>
internal sealed class SnapshotOptions
{
public const string ThemeAll = "all";
/// <summary>Dossier des captures (par défaut : artifacts\ui-snapshots à la racine de la solution).</summary>
public string? OutputDirectory { get; private set; }
/// <summary>Filtres sur les identifiants des scènes (« * » et « ? » acceptés) ; vide : toutes.</summary>
public List<string> Filters { get; } = new();
/// <summary>Échelle du rendu (1 = 96 ppp, 2 = écran « 200 % »).</summary>
public double Scale { get; private set; } = 1;
/// <summary>Null : thème de chaque jeu ; « all » : chaque thème intégré ; sinon ce thème.</summary>
public string? Theme { get; private set; }
/// <summary>Attente avant chaque rendu (chargement des images, animations d'ouverture).</summary>
public int WaitMilliseconds { get; private set; } = 450;
/// <summary>Arbre visuel de chaque scène (éléments, positions, textes) dans &lt;scène&gt;.tree.txt.</summary>
public bool Tree { get; private set; }
/// <summary>Pages supplémentaires (.p2.png…) quand une zone défile.</summary>
public bool Scroll { get; private set; } = true;
/// <summary>Fond derrière les fenêtres (coins arrondis, ombres) ; null : transparent.</summary>
public Color? Backdrop { get; private set; } = Color.FromRgb(0x1B, 0x1D, 0x22);
public bool List { get; private set; }
public bool Open { get; private set; }
public bool Help { get; private set; }
public const string Usage =
"""
UiSnapshot — captures de toutes les fenêtres du launcher (données de démonstration).
Utilisation : tools\ui-snapshot.bat [options]
--filter <motifs> scènes à capturer, séparées par des virgules (« * » accepté),
ex. --filter "main-*,admin-page-catalog-games"
--list affiche la liste des scènes, sans rien capturer
--scale <n> échelle du rendu : 1 (défaut), 1.5 ou 2 (texte plus net, fichiers plus lourds)
--theme <nom|all> thème des écrans : celui du jeu (défaut), « default », « exovival »,
ou « all » (chaque scène dans chaque thème intégré)
--wait <ms> attente avant chaque rendu (défaut 450)
--tree écrit aussi l'arbre visuel de chaque scène (<scène>.tree.txt)
--no-scroll pas de pages supplémentaires pour les zones qui défilent
--backdrop <couleur> fond derrière les fenêtres : #RRGGBB (défaut #1B1D22) ou « transparent »
--out <dossier> dossier des captures (défaut : artifacts\ui-snapshots)
--open ouvre index.html à la fin
--help cette aide
Résultat : un PNG par scène, index.html (planche de toutes les captures) et report.md
(problèmes détectés : texte coupé, débordement, erreurs de liaison, messages affichés…).
""";
public static SnapshotOptions Parse(string[] args)
{
var options = new SnapshotOptions();
for (var i = 0; i < args.Length; i++)
{
var arg = args[i];
string Next() => i + 1 < args.Length ? args[++i] : throw new ArgumentException($"Valeur manquante après {arg}.");
switch (arg.ToLowerInvariant())
{
case "--filter":
case "-f":
options.Filters.AddRange(Next().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
break;
case "--list":
options.List = true;
break;
case "--scale":
var scaleText = Next();
if (!double.TryParse(scaleText.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out var scale) ||
scale < 0.5 || scale > 4)
throw new ArgumentException($"Échelle invalide : « {scaleText} » (0.5 à 4).");
options.Scale = scale;
break;
case "--theme":
options.Theme = Next().Trim().ToLowerInvariant();
break;
case "--wait":
var waitText = Next();
if (!int.TryParse(waitText, NumberStyles.None, CultureInfo.InvariantCulture, out var wait) || wait > 10_000)
throw new ArgumentException($"Attente invalide : « {waitText} » (0 à 10000 ms).");
options.WaitMilliseconds = wait;
break;
case "--tree":
options.Tree = true;
break;
case "--no-scroll":
options.Scroll = false;
break;
case "--backdrop":
var backdrop = Next().Trim();
if (string.Equals(backdrop, "transparent", StringComparison.OrdinalIgnoreCase))
{
options.Backdrop = null;
}
else
{
try
{
options.Backdrop = (Color)ColorConverter.ConvertFromString(backdrop);
}
catch (Exception ex) when (ex is FormatException or NotSupportedException or NullReferenceException)
{
throw new ArgumentException($"Couleur invalide : « {backdrop} » (#RRGGBB ou « transparent »).");
}
}
break;
case "--out":
case "-o":
options.OutputDirectory = Next();
break;
case "--open":
options.Open = true;
break;
case "--help":
case "-h":
case "/?":
options.Help = true;
break;
default:
throw new ArgumentException($"Option inconnue : « {arg} ».");
}
}
return options;
}
}
+172
View File
@@ -0,0 +1,172 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Windows;
using System.Windows.Threading;
using GameLauncher.Core.Utils;
using GameLauncher.Services;
using UiSnapshot.Scenes;
namespace UiSnapshot;
/// <summary>Déroulement : préparation des données de démonstration, scènes, rapport.</summary>
internal sealed class SnapshotRunner
{
/// <summary>Fichier témoin : seul un dossier qui le contient est nettoyé avant une nouvelle série complète.</summary>
private const string MarkerFile = ".ui-snapshot";
private readonly SnapshotOptions _options;
private readonly Application _app;
public SnapshotRunner(SnapshotOptions options, Application app)
{
_options = options;
_app = app;
}
public async Task<int> RunAsync()
{
SynchronizationContext.SetSynchronizationContext(new DispatcherSynchronizationContext(_app.Dispatcher));
var themes = ResolveThemes();
var repository = Fixture.FindRepositoryRoot();
using var fixture = new Fixture(repository);
var sets = new ISceneSet[] { new PlayerScenes(fixture), new AdminScenes(fixture) };
if (_options.List)
{
foreach (var scene in sets.SelectMany(s => s.List(themes)))
Console.WriteLine($"{scene.Id,-48} {scene.Title}");
return 0;
}
var output = Path.GetFullPath(_options.OutputDirectory ??
Path.Combine(repository ?? Environment.CurrentDirectory, "artifacts", "ui-snapshots"));
PrepareOutput(output);
Log.Init(output); // journal du launcher (launcher.log) à côté des captures
var capture = new Capture(_options, output, themes);
using var bindings = BindingErrors.Listen(capture);
_app.DispatcherUnhandledException += (_, e) =>
{
capture.Problem($"Exception non gérée : {e.Exception.GetType().Name} : {e.Exception.Message}");
e.Handled = true;
};
Console.WriteLine($"Captures dans {output}");
var clock = Stopwatch.StartNew();
foreach (var set in sets)
{
try
{
await set.RunAsync(capture);
}
catch (Exception ex)
{
capture.GlobalNotes.Add($"⚠ {set.GetType().Name} interrompu : {ex.GetType().Name} : {ex.Message}");
Console.WriteLine($" ✗ {set.GetType().Name} interrompu : {ex.Message}");
}
}
foreach (var note in fixture.Notes) capture.GlobalNotes.Add(note);
Report.Write(capture, _options, clock.Elapsed);
var shots = capture.Records.Count(r => r.Error is null && r.Skipped is null);
var problems = capture.Records.Sum(r => r.Issues.Count + r.Bindings.Count);
Console.WriteLine();
Console.WriteLine($"{shots} capture(s), {capture.Records.Count(r => r.Error is not null)} échec(s), {problems} problème(s) signalé(s) " +
$"en {clock.Elapsed.TotalSeconds:0} s.");
Console.WriteLine($"Planche : {Path.Combine(output, "index.html")}");
Console.WriteLine($"Rapport : {Path.Combine(output, "report.md")}");
if (capture.Records.Count == 0) Console.WriteLine("Aucune scène ne correspond au filtre (voir --list).");
if (_options.Open)
{
try
{
Process.Start(new ProcessStartInfo(Path.Combine(output, "index.html")) { UseShellExecute = true });
}
catch (Exception ex)
{
Console.WriteLine($"Ouverture de index.html impossible : {ex.Message}");
}
}
return capture.Failed ? 1 : 0;
}
private IReadOnlyList<string?> ResolveThemes()
{
var builtIn = ThemeManager.BuiltInThemes.ToList();
if (_options.Theme is null) return new string?[] { null };
if (_options.Theme == SnapshotOptions.ThemeAll) return builtIn.Cast<string?>().ToList();
if (!builtIn.Contains(_options.Theme, StringComparer.OrdinalIgnoreCase))
throw new ArgumentException($"Thème inconnu : « {_options.Theme} » (thèmes intégrés : {string.Join(", ", builtIn)}, ou « all »).");
return new string?[] { _options.Theme };
}
/// <summary>
/// Crée le dossier ; une série complète (sans --filter) efface d'abord les captures précédentes, mais
/// seulement dans un dossier créé par cet outil (fichier témoin) : jamais dans un dossier quelconque.
/// </summary>
private void PrepareOutput(string output)
{
var marker = Path.Combine(output, MarkerFile);
if (Directory.Exists(output) && File.Exists(marker) && _options.Filters.Count == 0)
{
foreach (var pattern in new[] { "*.png", "*.tree.txt", "report.md", "index.html", "launcher.log", "launcher.old.log" })
{
foreach (var file in Directory.GetFiles(output, pattern, SearchOption.TopDirectoryOnly))
{
try
{
File.Delete(file);
}
catch (IOException)
{
// fichier ouvert (image affichée…) : remplacé plus tard s'il est recapturé
}
}
}
}
Directory.CreateDirectory(output);
File.WriteAllText(marker, "Dossier des captures de tools\\UiSnapshot (son contenu est remplacé à chaque série complète).\r\n",
new UTF8Encoding(false));
}
}
/// <summary>Erreurs de liaison WPF (messages « System.Windows.Data Error ») attribuées à la scène en cours.</summary>
internal sealed class BindingErrors : TraceListener
{
private readonly Capture _capture;
private readonly StringBuilder _line = new();
private BindingErrors(Capture capture) => _capture = capture;
public static IDisposable Listen(Capture capture)
{
var listener = new BindingErrors(capture);
PresentationTraceSources.Refresh();
PresentationTraceSources.DataBindingSource.Listeners.Add(listener);
PresentationTraceSources.DataBindingSource.Switch.Level = SourceLevels.Warning;
return new Subscription(listener);
}
public override void Write(string? message) => _line.Append(message);
public override void WriteLine(string? message)
{
_line.Append(message);
var text = _line.ToString();
_line.Clear();
if (Dispatcher.FromThread(Thread.CurrentThread) is null) return; // message d'un autre fil : ignoré (rare)
_capture.OnBindingError(text);
}
private sealed class Subscription : IDisposable
{
private readonly BindingErrors _listener;
public Subscription(BindingErrors listener) => _listener = listener;
public void Dispose() => PresentationTraceSources.DataBindingSource.Listeners.Remove(_listener);
}
}
+28
View File
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Outil de développement « UiSnapshot » : rend chaque écran du launcher en PNG (fenêtre principale dans
tous ses états, paramètres, boîtes de dialogue, mise à jour du launcher, menu d'administration et
chacune de ses pages), avec des données de démonstration, sans serveur ni compte réel, puis signale
les problèmes de mise en page (texte coupé, débordement, erreurs de liaison…).
Lancement : tools\ui-snapshot.bat (voir LISEZMOI.md).
Jamais distribué aux joueurs : projet séparé, que le launcher ne référence pas.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<RootNamespace>UiSnapshot</RootNamespace>
<AssemblyName>UiSnapshot</AssemblyName>
<StartupObject>UiSnapshot.Program</StartupObject>
<Platforms>AnyCPU;x64</Platforms>
<IsPackable>false</IsPackable>
<SatelliteResourceLanguages>fr</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<!-- Le launcher lui-même : ses vraies fenêtres, thèmes, styles et ViewModels sont rendus tels quels. -->
<ProjectReference Include="..\..\src\GameLauncher\GameLauncher.csproj" />
</ItemGroup>
</Project>
+57 -780
View File
@@ -1,808 +1,85 @@
# Launcher 2.0.0
# Launcher 2.3.0
LAUNCHER - NOTES DE MISE À JOUR
Version majeure : le menu d'administration - Mise à jour du 1er novembre 2026
Menu d'administration : plusieurs jeux - Mise à jour du 7 novembre 2026
==================================================================
Version majeure : le launcher s'administre désormais depuis le launcher lui-même. Informations des jeux, notes de version, publication d'une nouvelle version, gestion des administrateurs, journal d'audit et signature de tout ce que reçoivent les joueurs.
Version consacrée à la gestion de plusieurs jeux dans le menu d'administration : les pages « Notes de version » et « Publier une version » demandent désormais de choisir le jeu avant d'afficher quoi que ce soit, pour ne jamais modifier ou publier le mauvais jeu par erreur.
Le tout repose sur un nouveau serveur, la Launcher API, et sur une authentification par clé publique / clé privée : aucun mot de passe, aucun secret dans l'exécutable, aucun secret sur le serveur.
Pour les joueurs, rien ne change : le menu d'administration est invisible, ne contacte le serveur qu'une fois ouvert, et les nouveautés côté joueur (catalogue des jeux, signature du contenu) sont rétrocompatibles et désactivables.
Pour les joueurs, rien ne change : le jeu, les mises à jour et le launcher fonctionnent comme avant.
------------------------------------------------------------------
EN BREF
------------------------------------------------------------------
- NOUVEAU : menu d'administration intégré : accès discret, connexion
par clé, verrouillage automatique.
- NOUVEAU : clé d'administrateur en fichier chiffré ou dans la puce
TPM 2.0 du PC (non exportable).
- NOUVEAU : tableau de bord : compte, droits, session, serveur,
jeux, approbations en attente.
- NOUVEAU : page « Jeux » : nom affiché, accroche, description,
liens, thème, icône, fond, ordre… sans republier le launcher.
- NOUVEAU : page « Notes de version » : éditeur graphique avec un
aperçu identique au launcher.
- NOUVEAU : page « Publier une version » : envoi du jeu depuis le
launcher, avec reprise et vérification.
- NOUVEAU : pages « Mes appareils », « Administrateurs » et
« Journal d'audit ».
- NOUVEAU : page « Signature » et signature de bout en bout :
versions et catalogue signés, vérifiés par les launchers des
joueurs.
- NOUVEAU : Launcher API : serveur ASP.NET Core durci (systemd,
nginx), modules, audit, outil en ligne de commande.
- QUALITÉ : 232 tests automatisés (unitaires et de bout en bout,
serveur démarré en mémoire).
- MODIFIÉ : « Notes de version » et « Publier une version » :
choix du jeu obligatoire quand plusieurs jeux sont déclarés.
- NOUVEAU : carte « Choisir le jeu » (une tuile par jeu), puis
liste « Jeu » pour en changer.
- CORRIGÉ : changer de jeu dans « Publier une version » efface
tout ce qui concernait le jeu précédent.
- CORRIGÉ : un envoi interrompu reste attaché à son jeu.
- AMÉLIORÉ : les confirmations et les résultats de publication
nomment le jeu.
- MODIFIÉ : « Fichiers du serveur » : plus de jeu choisi par défaut
pour le nettoyage du dossier « objects ».
- QUALITÉ : 2 nouveaux tests automatisés (255 au total).
------------------------------------------------------------------
OUVRIR LE MENU D'ADMINISTRATION
CHOIX DU JEU
------------------------------------------------------------------
- NOUVEAU : Ctrl + Maj + Alt + A dans la fenêtre du launcher, ou 5
clics rapides (en moins de 3 secondes) sur le logo en haut à
gauche.
- L'accès est discret mais pas secret : seule la connexion par clé,
vérifiée par le serveur, protège l'administration. Masquer le
bouton n'est qu'un confort.
- Fenêtre dédiée au design moderne (coins arrondis, icônes, thème
sombre) :
* à gauche : l'administrateur connecté, les pages auxquelles il a
droit, le temps restant avant verrouillage et le bouton
« Verrouiller » ;
* à droite : la page choisie.
- Seules les pages autorisées par les droits renvoyés par le serveur
sont affichées : le serveur reste seul juge.
- Sans « apiUrl » dans launcher.json, le menu indique clairement
qu'il n'est relié à aucun serveur.
- MODIFIÉ : avec plusieurs jeux, les pages « Notes de version » et
« Publier une version » s'ouvrent sur la carte « Choisir le
jeu » :
* une tuile par jeu : nom, identifiant et serveur ;
* aucun jeu choisi par défaut ;
* rien n'est chargé ni affiché avant le choix.
- NOUVEAU : une fois le jeu choisi, la liste « Jeu » en haut de la
page permet d'en changer :
* désactivée pendant un chargement ou un envoi ;
* confirmation demandée si des notes ne sont pas enregistrées.
- Avec un seul jeu déclaré, il est choisi automatiquement : rien
ne change par rapport à la version précédente.
------------------------------------------------------------------
VERROUILLAGE AUTOMATIQUE
PUBLIER UNE VERSION
------------------------------------------------------------------
La clé déchiffrée n'existe en mémoire que pendant la session. Le
menu se verrouille (session fermée, clé effacée, retour à l'écran de
connexion) dans les cas suivants :
- SÉCURITÉ : déclencheurs du verrouillage :
* inactivité : 10 minutes sans activité dans la fenêtre ;
* durée maximale : 8 heures de session ;
* Windows : session verrouillée (Win + L, mise en veille) ;
* manuel : Ctrl + L ou bouton « Verrouiller » ;
* serveur : session refusée (révocation, redémarrage du service).
- Tant que l'administrateur est actif, la session est prolongée
automatiquement sur le serveur toutes les 4 minutes.
- Fermer la fenêtre ou le launcher efface la clé, puis ferme la
session sur le serveur.
- La phrase de passe n'est jamais conservée : le champ est vidé dès
la tentative de connexion.
- Pendant l'envoi d'une version, le verrouillage pour inactivité est
suspendu (le verrouillage manuel, celui de Windows et la durée
maximale restent appliqués).
- CORRIGÉ : au changement de jeu, rien du jeu précédent ne reste
affiché ni utilisable :
* dossier de publication choisi et version prête ;
* version en ligne et sa signature ;
* avancement de l'envoi.
- CORRIGÉ : une réponse du serveur arrivée après un changement de
jeu est ignorée.
- CORRIGÉ : un envoi interrompu reste attaché à son jeu : le
bandeau « Reprendre / Abandonner » réapparaît quand on revient à
ce jeu.
- AMÉLIORÉ : les confirmations (publier, signer la version en
ligne) et le message de résultat nomment le jeu.
- SÉCURITÉ : un dossier de publication d'un autre jeu reste refusé
(identifiant du manifest contrôlé).
------------------------------------------------------------------
CLÉS D'ADMINISTRATEUR
FICHIERS DU SERVEUR
------------------------------------------------------------------
- NOUVEAU : clé rangée dans un fichier chiffré :
* algorithme ECDSA P-256 / SHA-256 ;
* phrase de passe de 12 caractères minimum, chiffrement AES-256,
PBKDF2-SHA256 avec 600 000 itérations ;
* copiable et sauvegardable ;
* fichiers sur le disque : la clé chiffrée (.key) et sa clé
publique (.pub).
- NOUVEAU : clé rangée dans la puce de sécurité TPM 2.0 du PC :
* clé ECDSA P-256 générée dans la puce, qui ne peut pas en sortir
(non exportable) ;
* code PIN de 6 caractères minimum, vérifié par la puce, avec
blocage anti-essais ;
* fichiers sur le disque : une référence .tpmkey (sans aucun
secret) et la clé publique .pub ;
* disparaît avec la carte mère, une réinitialisation du TPM ou une
réinstallation de Windows.
- Dossier proposé : %USERPROFILE%\.launcher-keys. Une clé n'écrase
jamais un fichier existant.
- Le choix TPM n'est proposé qu'après une vérification réelle de la
puce (création, signature et suppression d'une clé de test).
- SÉCURITÉ : à la connexion TPM, la clé publique de la puce est
comparée à celle du fichier de référence avant tout essai du code
PIN. Le fichier .tpmkey ne peut désigner que des clés
« GameLauncher.Admin.* ».
- Après un code PIN faux, le nombre d'essais restants est affiché.
Si la puce est bloquée, un décompte indique quand elle acceptera
de nouveau un code (état lu dans la puce).
- NOUVEAU : bouton « Supprimer cette clé de la puce » sur l'écran de
connexion (clé TPM uniquement, après confirmation).
- CONSEIL : avec une clé TPM, gardez aussi une clé fichier de
secours.
- SÉCURITÉ : clés refusées :
* la clé de signature du launcher (launcher-signing*.key, copies
comprises) n'est jamais proposée ni acceptée ;
* toute clé dont l'identifiant ou la clé publique est celle de la
clé racine intégrée au launcher est refusée, quel que soit le
nom du fichier.
- MODIFIÉ : nettoyage du dossier « objects » : avec plusieurs jeux,
aucun jeu n'est choisi par défaut ; la liste affiche « Choisir le
jeu à nettoyer... ».
- AMÉLIORÉ : les listes déroulantes du menu d'administration
peuvent afficher un texte indicatif quand rien n'est choisi.
------------------------------------------------------------------
CONNEXION ET REQUÊTES SIGNÉES
DÉVELOPPEMENT
------------------------------------------------------------------
- NOUVEAU : connexion par défi signé :
* le launcher demande un défi au serveur (sans état : valable 60
secondes, nonce de 256 bits, scellé par HMAC, réponse identique
pour une clé connue ou inconnue) ;
* il signe un message de connexion contenant l'origine,
l'identifiant du serveur, le défi, le nonce et la clé ;
* le serveur vérifie le sceau, l'échéance, la signature, que la
clé et le compte sont actifs, et que le défi n'a jamais été
utilisé ;
* il renvoie un jeton de session de 256 bits (seule son empreinte
est conservée) et les droits.
- SÉCURITÉ : toute requête qui modifie quelque chose porte, en plus
du jeton, une signature de la clé de l'administrateur (méthode,
chemin, empreinte du contenu, horodatage, nonce).
* un jeton volé ne permet aucune modification ;
* le journal d'audit sait quelle clé a signé chaque action.
- NOUVEAU : « J'ai une invitation » : crée la clé sur ce PC (fichier
ou TPM), l'enregistre auprès du serveur avec le code d'invitation
et une preuve de possession, puis affiche l'empreinte à
communiquer à la personne qui approuve.
* empreinte lisible : 5 groupes de 4 caractères (80 bits) ;
* sans code : création d'une clé seule (premier propriétaire, à
déclarer sur le serveur).
------------------------------------------------------------------
TABLEAU DE BORD
------------------------------------------------------------------
- NOUVEAU : page d'accueil du menu :
* compte connecté : nom, initiales, rôle et liste des droits ;
* clé utilisée : nom de l'appareil et identifiant ;
* session : heure de fin au plus tard ;
* serveur : adresse, version, identifiant ;
* jeux déclarés dans launcher.json (nom, identifiant, serveur du
manifest) ;
* pour les propriétaires : appareils en attente d'approbation et
invitations actives ;
* raccourcis vers les pages « Administrateurs » et « Mes
appareils » ;
* bouton « Actualiser ».
------------------------------------------------------------------
PAGE « JEUX » : LE CATALOGUE DES JEUX
------------------------------------------------------------------
Permission « games.edit » : propriétaires, administrateurs et
éditeurs. Les informations d'affichage des jeux se modifient
désormais sans recompiler ni republier le launcher. Les jeux restent
déclarés dans launcher.json : le serveur ne peut ni ajouter ni
retirer un jeu.
- NOUVEAU : réglages du launcher : nom du launcher, jeu affiché au
premier lancement, ordre des jeux (boutons ↑ ↓).
- NOUVEAU : réglages de chaque jeu :
* nom affiché et accroche ;
* description (texte long : 2000 caractères, 40 lignes) ;
* liens : Discord, YouTube, forum… jusqu'à 8, en https ;
* site web ;
* thème (thèmes intégrés uniquement) ;
* icône et fond (images PNG ou JPEG du serveur du catalogue) ;
* fréquence des recherches de mise à jour (0, ou de 5 à 1440
minutes) ;
* jeu masqué.
- SÉCURITÉ : restent dans l'exécutable, jamais modifiables à
distance : identifiants du launcher et des jeux, éditeur, adresses
du manifest, de la mise à jour, de l'API, du catalogue et du
trousseau, dossiers de données supprimés à la désinstallation,
dossier d'installation par défaut, exécutable et arguments du jeu.
- Ergonomie :
* champ vide = valeur de launcher.json, rappelée sous le champ ;
* pastille « modifié » sur les champs changés ;
* contrôle complet avant l'envoi, avec les mêmes règles que le
serveur ;
* si quelqu'un a publié entre-temps, rien n'est écrasé : la page
propose de recharger ;
* bandeau explicatif si la publication n'est pas configurée sur le
serveur.
- SÉCURITÉ : règles appliquées : 48 Ko au plus, 50 jeux, noms de 80
caractères, accroche de 200, adresses https uniquement (500
caractères, sans identifiants), images .png ou .jpg du même
serveur que le catalogue, thème désigné par un identifiant (jamais
un chemin ni du XAML distant).
- NOUVEAU : le catalogue est signé automatiquement à chaque
publication.
------------------------------------------------------------------
PAGE « NOTES DE VERSION »
------------------------------------------------------------------
Permission « changelog.edit » : propriétaires, administrateurs et
éditeurs. Corrige les notes des versions déjà publiées, directement
dans le manifest.json du serveur.
- NOUVEAU : éditeur graphique :
* liste des versions publiées ;
* en-tête : titre, sous-titre, introduction ;
* sections, puis éléments : modification ou commentaire,
étiquette, sous-points, note ;
* boutons ↑ ↓ pour réordonner sections et éléments.
- NOUVEAU : aperçu en direct avec exactement la présentation du
launcher (mêmes modèles, mêmes limites).
- Une ancienne version au format « liste à plat » est présentée en
sections et enregistrée au format enrichi ; la liste à plat est
régénérée pour les anciens launchers.
- « Enregistrer » ne change que les notes : fichiers, archive,
version et exécutable restent identiques.
- Si le manifest a changé entre-temps (nouvelle version publiée,
autre administrateur), rien n'est écrasé et la page propose de
recharger.
- Les limites sont vérifiées avant l'envoi, sans rien tronquer en
silence.
- NOUVEAU : ManifestBuilder, option « --remote-manifest <adresse> »
(ajoutée à tools\publish-example.bat) : l'historique des notes est
repris du manifest en ligne, seule la nouvelle version vient de
notes.txt.
* les corrections faites depuis le menu d'administration ne sont
plus perdues à la publication suivante ;
* erreur 404 : première publication (historique local) ; toute
autre erreur arrête la publication ;
* republier une version sans « --notes » conserve ses notes
existantes.
------------------------------------------------------------------
PAGE « PUBLIER UNE VERSION »
------------------------------------------------------------------
Permission « releases.publish » : propriétaires et administrateurs.
Publie une nouvelle version du jeu depuis le launcher, à partir du
dossier produit par le .bat (ManifestBuilder, qui reste l'outil de
génération).
- NOUVEAU : déroulement :
* choix du manifest.json produit par le .bat ;
* contrôle local du dossier : manifest valide, archive et fichiers
présents avec la bonne taille, chemins sûrs (mêmes règles que le
serveur) ;
* rappel de la version en ligne, avec un avertissement si le
numéro est identique ou inférieur ;
* envoi du manifest, contrôlé par le serveur, qui répond avec la
liste des fichiers absents de son dossier objects/ ;
* envoi de ces fichiers uniquement : petits fichiers par lots (des
centaines par requête), gros fichiers par morceaux ;
* envoi de l'archive ZIP par morceaux de 4 Mo ;
* mise en ligne : manifest.json en dernier, remplacé d'un seul
coup ; l'historique des notes du serveur est conservé.
- NOUVEAU : reprise : « Interrompre » garde ce qui a été reçu,
« Publier » reprend à la position exacte.
- Coupures réseau, délais, serveur occupé : l'état est relu sur le
serveur et l'envoi continue automatiquement après une courte
pause, affichée sur la page.
- SÉCURITÉ : chaque fichier est vérifié par son empreinte SHA-256
des deux côtés ; un fichier modifié depuis le .bat est refusé.
- L'espace disque est vérifié par le serveur avant d'accepter les
fichiers.
- Si l'écriture du manifest échoue, l'ancienne archive est remise en
place.
- Si les fichiers en ligne ont changé pendant l'envoi (autre
publication, WinSCP), la mise en ligne est refusée ; une simple
correction des notes reste permise.
- Réponse perdue : le serveur garde le résultat 24 heures, un nouvel
essai le retrouve.
- Une publication à la fois par jeu ; celle d'un autre
administrateur n'est remplaçable qu'après 30 minutes sans envoi ;
abandon automatique après 48 heures.
- NOUVEAU : la version est signée juste avant la mise en ligne. La
reprise n'est pas affectée, et la signature est refaite si la mise
en ligne est retentée plus de 5 minutes après.
- NOUVEAU : « Signer la version en ligne » : pour une version déjà
en ligne (envoyée avec WinSCP ou avant la 2.0.0), depuis son
dossier. Accepté uniquement si les fichiers en ligne sont
identiques à ceux du dossier.
------------------------------------------------------------------
PAGE « MES APPAREILS »
------------------------------------------------------------------
Pour tout administrateur.
- NOUVEAU : liste de vos PC (clés) avec leur empreinte, leur date
d'ajout et leur dernière connexion.
- SÉCURITÉ : l'empreinte de l'appareil en cours est calculée sur
votre PC, pas annoncée par le serveur ; une différence avec celle
du serveur est signalée.
- NOUVEAU : « Ajouter un appareil » :
* crée une invitation « nouvel appareil » (code affiché une seule
fois) ;
* sur le nouveau PC : « J'ai une invitation » ;
* de retour ici : l'appareil en attente s'approuve en saisissant
l'empreinte affichée sur le nouveau PC.
- SÉCURITÉ : personne d'autre, pas même un propriétaire, ne peut
ajouter un appareil à votre compte.
- Suppression de vos propres invitations « nouvel appareil ». La
révocation d'un appareil perdu est réservée aux propriétaires.
------------------------------------------------------------------
PAGE « ADMINISTRATEURS »
------------------------------------------------------------------
Pour les propriétaires (permission « admins.manage »).
- Comptes en attente d'abord, puis actifs (vous en premier) ; les
comptes révoqués sur demande.
- NOUVEAU : inviter : nom et rôle, puis code d'invitation à usage
unique (160 bits, valable 24 heures).
* affiché une seule fois et copiable ;
* « J'ai transmis le code » l'efface de l'écran ;
* jamais enregistré par le launcher.
- NOUVEAU : approuver un premier appareil en saisissant l'empreinte
dictée par la personne. Format contrôlé sur le PC, comparaison par
le serveur ; l'empreinte d'une clé en attente n'est jamais
affichée : l'approbation prouve la vérification.
- NOUVEAU : changer un rôle, avec une confirmation renforcée pour
« Propriétaire ».
- NOUVEAU : révoquer un appareil ou un compte (effet immédiat) ;
supprimer une invitation.
- SÉCURITÉ : garde-fous : impossible de changer son propre rôle, de
se révoquer, de révoquer la clé de la session en cours, ou de
retirer le dernier propriétaire actif.
- Rôles et droits :
* propriétaire : gestion des administrateurs et de la signature,
journal d'audit, jeux, notes de version, publication ;
* administrateur : journal d'audit, jeux, notes de version,
publication ;
* éditeur : jeux et notes de version.
------------------------------------------------------------------
PAGE « JOURNAL D'AUDIT »
------------------------------------------------------------------
Pour les propriétaires et les administrateurs (permission
« audit.read »).
- NOUVEAU : connexions, invitations, approbations, publications,
modifications et refus, les plus récents d'abord.
- Chargement par pages de 200 entrées, 5 000 au plus en mémoire.
- NOUVEAU : filtres par catégorie, par résultat et par texte libre
(nom, adresse IP, clé…).
- NOUVEAU : export CSV des entrées filtrées, protégé contre
l'injection de formules dans les tableurs.
- Actions inscrites au journal :
* connexion : connexion, déconnexion, requête signée refusée ;
* administrateurs : invitation créée ou supprimée, enregistrement
d'une clé, approbation, révocation d'une clé ou d'un compte,
changement de rôle ;
* ligne de commande du serveur : ajout d'un propriétaire ou d'une
clé, révocations ;
* contenu : publication du catalogue, modification des notes de
version ;
* publication : début, mise en ligne, abandon, signature d'une
version en ligne ;
* signature : publication du trousseau (clés ajoutées et
retirées).
- SÉCURITÉ : les échecs sans signature valide sont détaillés 10 fois
par minute au plus, puis résumés (protection contre l'inondation
du journal) ; les échecs avec une signature valide (clé révoquée,
rejeu…) sont toujours détaillés.
------------------------------------------------------------------
PAGE « SIGNATURE » : SIGNATURE DE BOUT EN BOUT
------------------------------------------------------------------
Pourquoi ? Sans signature, quelqu'un qui prendrait le contrôle du
serveur pourrait distribuer un faux jeu. Désormais, chaque version
et le catalogue portent la signature de l'administrateur qui les a
publiés, et les launchers ne font confiance qu'aux clés d'un
trousseau signé hors ligne. Le serveur ne détient aucune clé privée.
- Chaîne de confiance :
* la clé racine (hors ligne, celle des mises à jour du launcher)
signe le trousseau keyring.json ;
* le trousseau autorise les clés des administrateurs ;
* ces clés signent le manifest.json de chaque version et le
catalog.json ;
* le launcher des joueurs vérifie le tout.
- NOUVEAU : page « Signature » (propriétaires) :
* trousseau publié : numéro, date, nombre de clés, signature
racine vérifiée par ce launcher, adresse à mettre dans
launcher.json ;
* trousseau proposé, calculé à partir des appareils actifs des
comptes autorisés à publier, avec les différences (à ajouter, à
retirer, autorisations modifiées) ;
* autorisation « versions du jeu » si le rôle peut publier une
version, « catalogue » s'il peut modifier les jeux ;
* état de chaque contenu en ligne (versions, catalogue) : signé ou
non, par qui, valide ou non pour les launchers ;
* alerte si un contenu deviendrait invalide avec le trousseau
proposé (à re-signer d'abord) ;
* « Exporter la demande… » : fichier à signer hors ligne (possible
aussi pour remplacer un trousseau refusé) ;
* « Publier le trousseau signé… » : signature racine et numéro
vérifiés avant l'envoi ; le serveur exige un numéro strictement
croissant.
- SÉCURITÉ : les noms d'appareils sont nettoyés (caractères
invisibles, inversion du sens d'écriture) et la page rappelle
qu'ils sont publics.
- NOUVEAU : outil tools\sign-keyring.bat : signature hors ligne du
trousseau.
* glissez la demande sur le .bat : elle est comparée au trousseau
signé précédent (keyring-N.json du même dossier) ;
* chaque nouvelle clé ou autorisation élargie est signalée en
couleur ;
* rappel de vérifier ces empreintes hors du serveur (de vive voix,
avec la page « Mes appareils » de chaque personne) ;
* confirmation, phrase de passe de la clé racine, puis écriture de
keyring-N.json (à garder : il sert de référence la fois
suivante) ;
* une clé racine absente de update-signing.pub est refusée.
- Ce qui est signé :
* manifest.json : jeu, version, exécutable, arguments, archive
(adresse, taille, empreinte), dossier des fichiers, liste des
fichiers (chemin, taille, empreinte) ;
* non couvert dans le manifest : notes de version et date
(corrigeables en ligne, jamais exécutées) ;
* catalog.json : tout le contenu affiché (textes, liens, thèmes,
images référencées, ordre) ;
* non couvert dans le catalogue : révision, date et contenu des
images.
- La signature est intégrée au fichier JSON : les launchers 1.x
l'ignorent et continuent de fonctionner.
------------------------------------------------------------------
SIGNATURE : VÉRIFICATION PAR LES LAUNCHERS DES JOUEURS
------------------------------------------------------------------
- NOUVEAU : trois modes (« contentSigning ») :
* off : rien n'est vérifié (comportement de la 1.x, par défaut
sans adresse de trousseau) ;
* warn : signatures vérifiées, problèmes inscrits au journal,
contenu utilisé (période de mise en place) ;
* require : contenu non signé, mal signé ou plus ancien que le
dernier vu refusé.
- SÉCURITÉ : en mode « require » :
* manifest refusé : message « Mise à jour bloquée par sécurité »,
aucune installation ni mise à jour, le jeu installé reste
jouable ;
* catalogue refusé : ignoré, le cache précédent est conservé ;
* anti-retour en arrière : un contenu signé plus tôt que le
dernier accepté est refusé ;
* le catalogue en cache est revérifié à chaque démarrage, hors
ligne ;
* un catalogue dont deux jeux ne diffèrent que par la casse est
refusé, même signé.
- Trousseau côté joueur :
* téléchargé au démarrage, vérifié avec la clé racine, mis en
cache ;
* refusé s'il est plus ancien que le dernier vu, différent sous le
même numéro, ou plus ancien que « keyringMinSequence » ;
* hors ligne ou en cas d'erreur : le dernier trousseau vu est
utilisé, aucune erreur ne fait planter le launcher ;
* contenu signé par une clé inconnue : nouveau téléchargement du
trousseau, au plus une fois toutes les 5 minutes ;
* date de signature retenue plafonnée à « maintenant + 1 jour » :
une horloge déréglée ne bloque pas les versions suivantes.
------------------------------------------------------------------
NOUVEAUTÉS POUR LES JOUEURS
------------------------------------------------------------------
- NOUVEAU : fiche du jeu enrichie : description et boutons de liens
(site, Discord, vidéo, forum… avec icônes).
- NOUVEAU : nom affiché, accroche, thème, icône, fond et ordre des
jeux mis à jour depuis le serveur, sans nouvelle version du
launcher.
- AMÉLIORÉ : démarrage sans attente : le catalogue en cache est
appliqué immédiatement ; la version à jour est téléchargée en
parallèle et appliquée si elle arrive en 2,5 secondes (sinon au
démarrage suivant).
- Hors ligne : dernier catalogue en cache, sinon les valeurs de
launcher.json.
- SÉCURITÉ : images contrôlées (vrai PNG ou JPEG, 4 Mo au plus),
téléchargées uniquement depuis le serveur du catalogue, mises en
cache ; fonds réduits à 3840 pixels.
- Le nom affiché ne change ni le raccourci, ni le registre, ni les
dossiers.
- Un jeu masqué reste affiché s'il est installé ou demandé par un
raccourci ; au moins un jeu est toujours affiché.
- NOUVEAU : vérification des signatures, activable progressivement
(« warn » puis « require »).
------------------------------------------------------------------
LAUNCHER API (SERVEUR)
------------------------------------------------------------------
Nouveau serveur ASP.NET Core (.NET 8), publié pour Linux x64 en
version autonome, derrière nginx.
- NOUVEAU : modules du serveur :
* System : informations et état de santé ;
* Auth : défi, connexion, session, déconnexion, enregistrement
d'une clé ;
* Admins : administrateurs, clés, invitations, rôles, révocations,
« mon compte » ;
* Audit : journal d'audit ;
* Catalog : lecture et publication de catalog.json ;
* Changelog : notes de version dans le manifest publié ;
* Publishing : envoi par morceaux, reprise, mise en ligne ;
* Signing : trousseau, état et signature des contenus.
- Routes de l'API (/api/v1) et droits :
* info, health : publiques ; auth/challenge, auth/login :
publiques, limitées ; enrollment : code d'invitation ;
* auth/logout, auth/me, admins/me : administrateur connecté ;
* admins, admins/invitations (liste) : admins.manage ; création et
suppression d'invitations : connecté, droits revérifiés ;
* approbation d'une clé : propriétaire (premier appareil) ou
titulaire du compte (nouvel appareil) ;
* révocation d'une clé ou d'un compte, changement de rôle :
admins.manage ;
* audit : audit.read ; catalog : lecture pour tous, publication
games.edit ;
* games/{jeu}/changelog : lecture pour tous, modification
changelog.edit ;
* games/{jeu}/publications et publications/{id}/… (manifest,
archive, objets, mise en ligne) : releases.publish ;
* signing, keyring : admins.manage ; games/{jeu}/signature :
releases.publish.
Toutes les routes qui modifient quelque chose exigent une requête
signée.
- Stockage :
* aucune base de données : fichiers JSON écrits d'un seul coup
(fichier temporaire puis renommage), verrou entre processus,
sauvegarde de la version précédente ;
* données privées dans /var/lib/launcher-api (droits 700), en
dehors du dossier SFTP ;
* uniquement des clés publiques et des empreintes de codes
d'invitation : une fuite complète des données ne permet pas de
se connecter ;
* historiques : 20 dernières versions du catalogue, 20 derniers
manifests modifiés, trousseaux successifs ;
* fichiers publics (catalogue, trousseau) écrits lisibles par
nginx (0644), malgré le masque restrictif du service.
- Limites :
* requêtes générales : 300 par minute et par adresse IP ;
* connexion : 20 par minute et par adresse IP ;
* morceaux de publication : 1500 par minute et par adresse IP
(limite dédiée) ;
* taille des requêtes : 64 Ko (8 Mo pour les routes d'envoi) ;
* sessions : 20 minutes d'inactivité, 8 heures au maximum, 5 par
administrateur ;
* horloge des requêtes signées : 2 minutes d'écart au plus ;
* date des signatures de contenu : 10 minutes d'écart au plus avec
l'heure du serveur.
Chaque refus 429 annonce son délai (Retry-After). Les adresses
IPv6 sont regroupées par /64.
- NOUVEAU : récupération en ligne de commande sur le serveur
(GameLauncher.Server, compte deploy) :
* add-owner : créer le premier propriétaire ;
* add-key : restaurer l'accès d'un compte qui n'a plus d'appareil
;
* list : lister les administrateurs ;
* revoke-key, revoke-admin : révoquer une clé ou un compte ;
* server-id : identifiant du serveur.
Une révocation en ligne de commande prend effet immédiatement, y
compris sur les sessions ouvertes.
------------------------------------------------------------------
SÉCURITÉ
------------------------------------------------------------------
Principe fondamental : le launcher est distribué à tous les joueurs,
tout ce qu'il contient est public. Le serveur est la seule barrière
de sécurité, et le serveur lui-même ne peut pas distribuer de
contenu sans la signature d'un administrateur.
- SÉCURITÉ : menaces et défenses :
* vol de mot de passe, hameçonnage : aucun mot de passe, clé
privée sur le PC (fichier chiffré ou puce TPM) ;
* force brute, saturation : limites par adresse IP (nginx et
serveur), défis sans état, sessions en nombre limité ;
* énumération des clés : défi identique pour une clé connue ou
inconnue, erreurs génériques ;
* rejeu : défi à usage unique (60 secondes), nonce et horodatage
des requêtes signées ;
* vol du jeton de session : inutile pour modifier (requêtes
signées), courte durée, lié à la clé ;
* fuite des données du serveur : uniquement des clés publiques et
des empreintes ;
* serveur compromis qui distribue un faux jeu : signature de bout
en bout, clé racine hors ligne ;
* serveur compromis qui ressert une ancienne version : anti-retour
en arrière (date de signature, numéro du trousseau) ;
* serveur compromis qui glisse sa clé dans le trousseau :
comparaison au trousseau précédent par sign-keyring, empreintes
vérifiées hors du serveur ;
* clé d'administrateur compromise : révocation, nouveau trousseau,
« keyringMinSequence » ;
* usurpation par un autre administrateur : un nouvel appareil
n'est approuvé que par le titulaire du compte ;
* erreur de manipulation : impossible de se révoquer, de changer
son propre rôle, de retirer le dernier propriétaire ;
* launcher.json modifié à côté de l'exe : les adresses de
confiance et les réglages de signature ne sont lus que dans
l'exe ;
* thème ou contenu distant malveillant : thèmes intégrés
uniquement, valeurs bornées, images contrôlées, même serveur ;
* export CSV piégé : toutes les cellules entre guillemets,
formules neutralisées ;
* en-têtes falsifiés : acceptés uniquement depuis la machine
elle-même, HTTPS exigé ;
* compromission du compte SFTP : données de l'API en dehors du
dossier SFTP.
------------------------------------------------------------------
DÉPLOIEMENT SUR LE SERVEUR
------------------------------------------------------------------
Guide complet pas à pas : server/deploy/LISEZMOI.md.
- NOUVEAU : script installer-api.sh : installation et mise à jour
vérifiées par empreinte SHA-256, programmes appartenant à root (le
service ne peut pas les modifier), réglages conservés, retour
automatique à la version précédente si le démarrage échoue.
- SÉCURITÉ : service systemd durci : compte deploy, masque 0077,
aucun privilège supplémentaire, système en lecture seule, dossiers
personnels et périphériques masqués, aucun bit spécial, réseau
limité à la machine elle-même, mémoire limitée à 512 Mo.
- Écriture autorisée uniquement dans les dossiers nécessaires, par
des compléments systemd séparés : catalogue et trousseau d'une
part, dossier du jeu d'autre part.
- nginx : API et limites, catalog.json et keyring.json sans cache,
routes d'envoi des publications (8 Mo, zone de limite dédiée).
- Dossier objects/ et ses 256 sous-dossiers créés à l'avance en 2750
(groupe www-data transmis).
- Étapes du guide : 3 bis (catalogue), 3 ter (notes de version), 3
quater (publication), 3 quinquies (signature), 4 (clé
d'administrateur), 8 (mise à jour du serveur).
------------------------------------------------------------------
OUTILS ET CONFIGURATION
------------------------------------------------------------------
- NOUVEAU : outil tools\publish-server.bat : publie le serveur
(Linux x64 autonome) avec son empreinte SHA-256.
- NOUVEAU : outil tools\admin-key.bat (AdminKeyTool) :
administration en ligne de commande :
* create, fingerprint : créer une clé, afficher son empreinte ;
* login-test, enroll : tester la connexion, enregistrer une clé
avec une invitation ;
* admins, invite, approve, role : lister, inviter, approuver,
changer un rôle ;
* revoke-key, revoke-admin, audit : révoquer, lire le journal.
- NOUVEAU : outil tools\sign-keyring.bat (LauncherPublisher
« sign-keyring ») : signe le trousseau avec la clé racine, hors
ligne.
- AMÉLIORÉ : outil tools\publish-example.bat (ManifestBuilder) :
option « --remote-manifest ».
- NOUVEAU : nouvelles clés de launcher.json, toutes facultatives (en
Release, lues uniquement dans le launcher.json intégré à l'exe) :
* apiUrl : adresse de la Launcher API (sans elle : pas de menu
d'administration) ;
* catalogUrl : catalogue des jeux ;
* keyringUrl : trousseau des clés autorisées ;
* contentSigning : « off », « warn » ou « require » (par défaut
« warn » si keyringUrl est présente, sinon « off ») ;
* keyringMinSequence : numéro minimal du trousseau accepté, à
relever après le retrait d'une clé compromise ;
* par jeu, nouvelles valeurs par défaut possibles : description,
links, icon, background, hidden.
------------------------------------------------------------------
CORRECTIONS ET STABILISATION
------------------------------------------------------------------
Corrigé pendant le développement de la 2.0.0, avant sa sortie :
- CORRIGÉ : publication : erreur « Le thread appelant ne peut pas
accéder à cet objet… » au clic sur « Publier ». L'avancement et
les boutons sont désormais toujours mis à jour sur le fil de
l'interface.
- CORRIGÉ : publication : le bandeau « L'envoi de la version … a été
interrompu » s'affichait pendant un envoi normal. Il n'apparaît
plus qu'une fois l'envoi réellement arrêté.
- CORRIGÉ : publication : pauses de plusieurs dizaines de secondes
pendant l'envoi. Limite de requêtes dédiée aux envois, délai
Retry-After respecté, pauses courtes (1 seconde doublée, 30
secondes au plus), zone nginx dédiée.
- CORRIGÉ : publication : échec de la mise en ligne (« pas les
droits ou plus de place ») sous systemd. Les sous-dossiers des
fichiers sont créés sans demander de bit spécial, et le guide les
fait créer à l'avance.
- CORRIGÉ : administration : erreurs du travail en arrière-plan
correctement remontées.
- CORRIGÉ : journal d'audit : le nom et le rôle de l'invitation
supprimée sont désormais inscrits.
- SÉCURITÉ : relecture de sécurité de la signature :
* identifiants du catalogue pris tels quels (sans mise en
minuscules) dans l'empreinte ;
* trousseau ou manifest incomplets refusés sans erreur ;
* noms d'appareils trompeurs refusés ;
* téléchargement du trousseau qui ne fait jamais planter le
launcher ;
* numéro minimal du trousseau, revérification du cache du
catalogue ;
* clé racine refusée à la connexion, empreinte locale sur « Mes
appareils ».
------------------------------------------------------------------
ARCHITECTURE TECHNIQUE
------------------------------------------------------------------
- NOUVEAU : projets de la solution :
* GameLauncher.Contracts : protocole partagé launcher / serveur
(routes, échanges, rôles, messages signés, catalogue, notes,
publication, signature) ;
* GameLauncher.ApiClient : client de l'API (défi signé, requêtes
signées, fichier de clé chiffré) ;
* GameLauncher.Modules : socle des modules, sans WPF (modules,
registre, pages, boîtes de dialogue, MVVM) ;
* GameLauncher.Modules.Admin : module « Administration » (services
et ViewModels testés sans interface) ;
* GameLauncher.Server : la Launcher API ;
* AdminKeyTool : clé d'administrateur et administration en ligne
de commande ;
* GameLauncher.Tests : tests unitaires et de bout en bout.
- Socle modulaire : le menu d'administration est le premier module ;
les futures fonctionnalités (actualités, amis, succès, sauvegardes
dans le cloud…) s'y brancheront de la même façon, côté launcher et
côté serveur.
- Ajouter une page : une classe de page, son descripteur et sa vue ;
la fenêtre d'administration ne change pas.
- Aucun paquet NuGet supplémentaire en dehors du projet de tests.
- Documentation : docs/ADMIN-ARCHITECTURE.md (architecture,
sécurité, décisions), README.md (§8), server/deploy/LISEZMOI.md.
------------------------------------------------------------------
MISE À NIVEAU DEPUIS LA 1.X
------------------------------------------------------------------
Aucune action n'est requise pour les joueurs : sans les nouvelles
clés dans launcher.json, le launcher 2.0.0 se comporte comme la 1.x.
- IMPORTANT : activer l'administration :
* installer la Launcher API sur le serveur (guide, étapes 1 à 3)
et créer le premier propriétaire (add-owner) ;
* ajouter apiUrl à launcher.json, recompiler et publier le
launcher ;
* activer les pages une à une : catalogue (3 bis), notes de
version (3 ter), publication (3 quater) ;
* inviter les autres administrateurs depuis la page
« Administrateurs ».
- IMPORTANT : activer la signature (guide, étape 3 quinquies) :
* mettre à jour le serveur et ajouter le bloc keyring.json à nginx
;
* page « Signature » : exporter la demande, la signer avec
tools\sign-keyring.bat, publier le trousseau signé ;
* signer la version en ligne (page « Publier une version ») et
republier le catalogue (page « Jeux ») ;
* launcher.json : keyringUrl, contentSigning « warn » et
keyringMinSequence 1, publier le launcher, vérifier le journal ;
* puis contentSigning « require » ; en option, exiger la signature
côté serveur (LauncherApi:ContentSigning:Require).
------------------------------------------------------------------
LIMITES CONNUES
------------------------------------------------------------------
- LIMITE : le contenu des images du catalogue n'est pas signé (un
serveur compromis pourrait les remplacer, pas les textes ni les
liens).
- LIMITE : un launcher qui n'a encore jamais vu de trousseau accepte
tout trousseau signé par la racine dont le numéro atteint
« keyringMinSequence ».
- LIMITE : un appareil ajouté au trousseau pendant qu'un launcher
tourne est connu au plus tard 5 minutes après, ou au démarrage
suivant.
- LIMITE : retirer une clé du trousseau invalide le contenu qu'elle
a signé (la page « Signature » le signale : re-signer d'abord).
- LIMITE : clé TPM :
* non sauvegardable ;
* le serveur ne peut pas prouver qu'une clé est dans une puce
(protection pour l'administrateur, pas une règle du serveur) ;
* le blocage anti-essais est commun à toute la puce (il compte
aussi les codes PIN Windows Hello).
- LIMITE : la création d'une nouvelle version et de ses notes se
fait toujours avec le .bat (ManifestBuilder) ; le menu publie et
corrige.
- LIMITE : un redémarrage du service déconnecte tous les
administrateurs (sessions en mémoire).
- Outil de captures d'écran : nouvelle capture « Notes de version
(jeu choisi) » ; la capture « version prête à publier » choisit
le jeu si besoin.
- Serveur (Launcher API) inchangé : rien à redéployer.
- Documentation : README.md et docs/ADMIN-ARCHITECTURE.md mis à
jour.
La sécurité des mises à jour du launcher ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
+22
View File
@@ -0,0 +1,22 @@
@echo off
rem ============================================================================
rem Captures de toutes les fenêtres du launcher (outil tools\UiSnapshot).
rem Compile le launcher et l'outil, puis écrit dans artifacts\ui-snapshots :
rem - un PNG par écran (états de la fenêtre principale, paramètres, dialogues,
rem mise à jour du launcher, menu d'administration et chacune de ses pages) ;
rem - index.html : planche de toutes les captures ;
rem - report.md : problèmes détectés (texte coupé, débordement, liaisons…).
rem Données de démonstration : aucun serveur, aucune clé ni donnée réelle.
rem
rem Exemples :
rem tools\ui-snapshot.bat
rem tools\ui-snapshot.bat --filter "main-*" --scale 2
rem tools\ui-snapshot.bat --filter "admin-page-*" --theme all --open
rem tools\ui-snapshot.bat --list
rem ============================================================================
setlocal
chcp 65001 >nul
cd /d "%~dp0.."
dotnet run --project "tools\UiSnapshot\UiSnapshot.csproj" -c Debug -- %*
set RESULT=%ERRORLEVEL%
endlocal & exit /b %RESULT%