Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ac3d153ab | ||
|
|
7d26a4d59c | ||
|
|
924735a0dc | ||
|
|
d5a992ad62 | ||
|
|
e2523d1bff | ||
|
|
d444940b63 | ||
|
|
8e69ec108e | ||
|
|
84bcb80108 | ||
|
|
e9f650ef9e | ||
|
|
27b57526ee | ||
|
|
3996f580f6 | ||
|
|
ca0eb044e8 | ||
|
|
5b0999e47c | ||
|
|
9f6210598c | ||
|
|
08e1476726 | ||
|
|
818716b7a9 | ||
|
|
4971c926ac | ||
|
|
99502a2003 | ||
|
|
81108699c3 | ||
|
|
7cce8676b4 | ||
|
|
0f8f64f00f | ||
|
|
68ada960cd | ||
|
|
b08cad3e8e | ||
|
|
42fc1e58cc | ||
|
|
b2f1415bff | ||
|
|
d499bfb11a | ||
|
|
bddd46e9bb | ||
|
|
b774ca119f | ||
|
|
fbfb456b5d | ||
|
|
15b234995b | ||
|
|
3eada0b878 |
No files matched your search
@@ -5,6 +5,7 @@ Publish/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
Claude outputs/
|
||||
|
||||
# ---> VisualStudio
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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é.
|
||||
|
||||
@@ -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)";
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 :
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.<type de la page> » 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 <GameId> »).</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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}" />
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// (<scène>.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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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).");
|
||||
}
|
||||
@@ -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}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <scène>.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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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%
|
||||
Reference in new issue
Block a user