Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84bcb80108 | ||
|
|
e9f650ef9e | ||
|
|
27b57526ee | ||
|
|
3996f580f6 | ||
|
|
ca0eb044e8 | ||
|
|
5b0999e47c | ||
|
|
9f6210598c | ||
|
|
08e1476726 | ||
|
|
818716b7a9 | ||
|
|
4971c926ac | ||
|
|
99502a2003 | ||
|
|
81108699c3 | ||
|
|
7cce8676b4 | ||
|
|
0f8f64f00f | ||
|
|
68ada960cd | ||
|
|
b08cad3e8e | ||
|
|
42fc1e58cc | ||
|
|
b2f1415bff | ||
|
|
d499bfb11a | ||
|
|
bddd46e9bb | ||
|
|
b774ca119f | ||
|
|
fbfb456b5d | ||
|
|
15b234995b | ||
|
|
3eada0b878 | ||
|
|
92ae5cbe63 | ||
|
|
86593a43ee | ||
|
|
8a7902e5e8 | ||
|
|
50ef1025d8 | ||
|
|
bcae86f1a1 | ||
|
|
bcde2a0dac | ||
|
|
86fef875a6 | ||
|
|
1b397ec930 | ||
|
|
c89bd62aff | ||
|
|
f556848da4 | ||
|
|
1cf9e87e82 | ||
|
|
dc09c3ecef | ||
|
|
9aa3876a0e |
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,8 @@ 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
|
||||
EndProjectSection
|
||||
@@ -58,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}"
|
||||
@@ -160,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
|
||||
@@ -177,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` |
|
||||
@@ -489,7 +496,14 @@ Chantier en cours, découpé en phases : architecture, sécurité et planning da
|
||||
Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitation, tableau de bord).
|
||||
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**.
|
||||
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
|
||||
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é,
|
||||
@@ -510,7 +524,8 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
|
||||
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é,
|
||||
@@ -518,7 +533,11 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
|
||||
`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),
|
||||
@@ -546,9 +565,32 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
|
||||
à transmettre, affiché une seule fois), approuver le premier appareil d'un invité avec l'empreinte
|
||||
qu'il vous dicte, changer un rôle, révoquer un appareil ou un compte, supprimer une invitation.
|
||||
Impossible de changer son propre rôle, de se révoquer, ou de retirer le dernier propriétaire actif.
|
||||
* **Signature du contenu** (phase 7) : chaque version mise en ligne et le catalogue portent la signature de
|
||||
l'administrateur qui les publie (automatique depuis le launcher ; une version envoyée avec WinSCP se signe
|
||||
ensuite depuis la page « Publier une version »). Les launchers ne font confiance qu'aux clés du **trousseau**
|
||||
(`keyring.json`), signé **hors ligne** par la clé racine du launcher avec `tools\sign-keyring.bat` et publié
|
||||
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
|
||||
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
|
||||
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
|
||||
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
|
||||
`"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 :
|
||||
@@ -558,8 +600,8 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
|
||||
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é.
|
||||
@@ -569,4 +611,5 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
|
||||
| Installer le serveur sur le VPS, créer le premier propriétaire, récupération | `server/deploy/LISEZMOI.md` |
|
||||
| Publier le serveur | `tools\publish-server.bat` (Linux x64 autonome + empreinte SHA-256) |
|
||||
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
|
||||
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
|
||||
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
|
||||
+115
-13
@@ -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.
|
||||
@@ -499,27 +500,127 @@ 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`).
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7, recommandée)
|
||||
### 11 ter. Fichiers du serveur
|
||||
|
||||
Aujourd'hui `manifest.json` n'est pas signé : une prise de contrôle du VPS permettrait de
|
||||
distribuer un faux jeu. La phase 7 ajoute :
|
||||
Module serveur `Files` (`FilesModule`, `ServerFilesStore`) et module launcher `FilesAdminModule` (page « Fichiers du
|
||||
serveur »), permission `files.manage`.
|
||||
|
||||
* `keyring.json` : liste des clés publiques des administrateurs, **signée par la clé racine**
|
||||
(votre clé de signature du launcher, déjà intégrée à l'exe) ;
|
||||
* une **signature détachée** (`manifest.json.sig`, `catalog.json.sig`) produite par la clé de
|
||||
l'administrateur au moment de la publication ou de l'édition — les anciens launchers ignorent
|
||||
ces fichiers ;
|
||||
* côté joueur, vérification puis refus de tout contenu non signé par une clé du trousseau.
|
||||
| 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`) |
|
||||
|
||||
Le format des requêtes signées de la phase 1 est déjà compatible avec ce modèle.
|
||||
* **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 :
|
||||
|
||||
**Ancre de confiance** : la ou les clés racine intégrées à l'exe (`Security\update-signing.pub`, celles des
|
||||
mises à jour du launcher). La clé privée racine reste **hors ligne** : le menu d'administration refuse toujours
|
||||
de l'ouvrir (nom de fichier réservé, et toute clé dont l'identifiant ou la clé publique est celle d'une racine
|
||||
intégrée, quel que soit le nom du fichier) ; elle ne sert qu'à `LauncherPublisher` (mises à jour du launcher,
|
||||
`sign-keyring`).
|
||||
|
||||
**Trousseau** (`keyring.json`, `Contracts/Signing/Keyring`) : `sequence` croissante, `issuedAt`, clés
|
||||
(identifiant, clé publique SPKI, nom, autorisations `releases` et/ou `catalog`), signature racine sur le SHA-256
|
||||
du trousseau canonique (message `GameLauncher/Keyring/v1`, clés et autorisations triées). Production : page
|
||||
**Signature** (propriétaires) › « Exporter la demande » (appareils ACTIFS des comptes actifs ; `releases` si le rôle
|
||||
a `releases.publish`, `catalog` s'il a `games.edit` ; noms débarrassés des caractères invisibles, de mise en forme
|
||||
ou d'inversion du sens d'écriture, refusés aussi par `Keyring.Validate`) → `tools\sign-keyring.bat` (compare la
|
||||
demande au trousseau signé précédent `keyring-N.json` du même dossier, vérifié avec `update-signing.pub`, et
|
||||
signale les **nouvelles clés** et autorisations élargies ; la demande vient du serveur et n'est donc pas digne de
|
||||
confiance : ces empreintes se vérifient hors du serveur, avec celle que la page **Mes appareils** calcule sur le
|
||||
PC de chacun — pas celle annoncée par le serveur ; confirmation, phrase de passe ; refuse une racine absente
|
||||
d'`update-signing.pub` ; écrit `keyring-N.json`) → « Publier le trousseau signé » (le launcher vérifie la racine et
|
||||
le numéro avant l'envoi ; l'export reste possible quand le trousseau publié est refusé par ce launcher). Les noms
|
||||
sont publics. Serveur : `PUT v1/keyring` (`admins.manage`,
|
||||
requête signée) contrôle la structure et un numéro strictement croissant, écrit le fichier public (par défaut à
|
||||
côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish` (clés ajoutées/retirées). La liste
|
||||
des administrateurs expose désormais la clé publique des clés **actives** seulement (celle d'une clé en attente
|
||||
permettrait de calculer l'empreinte à dicter).
|
||||
|
||||
**Signature du contenu** (`ContentSigning`) : propriété `signature` `{ keyId, signedAt, value }` dans le JSON
|
||||
(atomique avec le contenu, ignorée par les anciens launchers), sur le message `GameLauncher/Content/v1`
|
||||
(type, identifiant du jeu ou « catalog », date à la seconde, clé, condensé). Condensés canoniques, calculés par
|
||||
chacun à partir de ce qu'il a lu (même code dans `Contracts`) :
|
||||
* manifest : jeu, version, exécutable, arguments, archive (adresse, taille, SHA-256), base des objets, fichiers
|
||||
(chemin, taille, SHA-256) triés — **pas** les notes ni la date : les notes restent corrigeables en ligne (texte
|
||||
borné, jamais exécuté) ; le launcher des joueurs calcule le même condensé depuis son modèle `GameManifest` ;
|
||||
* catalogue : tout sauf `revision`, `updatedAt` et `signature` (posés par le serveur), jeux triés par identifiant
|
||||
**tel que publié** (sans mise en minuscules) ; un catalogue dont deux jeux ne diffèrent que par la casse est
|
||||
refusé par les launchers (`HasDuplicateGames`), même signé.
|
||||
|
||||
Les membres absents ou `null` (trousseau, manifest) ne provoquent jamais d'exception : trousseau refusé, condensé
|
||||
calculé sur des valeurs vides.
|
||||
|
||||
**Production des signatures** (clé de la session ; date = heure du serveur) : à la mise en ligne d'une version
|
||||
(`ReleaseUploader`, juste avant le commit : la reprise des envois, fondée sur l'empreinte du manifest, n'est pas
|
||||
touchée ; corps `PublicationCommitRequest`), à chaque publication du catalogue (page Jeux, sur le catalogue
|
||||
contrôlé tel qu'il sera publié), et pour une version déjà en ligne (WinSCP, antérieure) : page « Publier une
|
||||
version » › « Signer la version en ligne » depuis son dossier (`POST v1/games/{id}/signature`, accepté seulement si
|
||||
le condensé en ligne est celui du dossier). Le serveur vérifie chaque signature (clé = celle de la session, date à
|
||||
± 10 min de son heure — une date future bloquerait les mises à jour suivantes chez les joueurs, signature valide)
|
||||
et peut exiger la signature (`LauncherApi:ContentSigning:Require`). Il retire toute `signature` d'un manifest reçu
|
||||
et n'y met que celle de la mise en ligne. L'empreinte « contenu en ligne inchangé pendant l'envoi » ignore les
|
||||
notes et la signature. Un nouvel essai de mise en ligne plus de 5 minutes après la signature la refait.
|
||||
|
||||
**Côté joueur** (`Core/Trust/ContentTrustService`) : `keyringUrl` et `contentSigning` lus, en Release,
|
||||
uniquement dans le `launcher.json` intégré (`off` par défaut, `warn` si `keyringUrl` est présent, `require`).
|
||||
Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (structure, 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`
|
||||
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
|
||||
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
|
||||
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
|
||||
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
|
||||
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
|
||||
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
|
||||
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
|
||||
sécurité », pas d'installation ni de mise à jour, le jeu installé reste jouable), catalogue ignoré (cache conservé).
|
||||
Le cache du catalogue contient le catalogue tel que publié (signature comprise) : il est revérifié à chaque
|
||||
démarrage, hors ligne, avec le dernier trousseau vu (un cache non signé ou modifié sur le disque est ignoré en
|
||||
« require »). La date de signature retenue est plafonnée à « maintenant + 1 jour » (une horloge déréglée ne bloque
|
||||
pas les versions suivantes).
|
||||
|
||||
**Limites assumées** : images du catalogue non signées ; un launcher qui n'a jamais vu de trousseau accepte tout
|
||||
trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; retirer une clé du trousseau invalide le contenu qu'elle a signé
|
||||
(la page Signature le signale : re-signer d'abord).
|
||||
|
||||
## 13. Phases
|
||||
|
||||
@@ -533,7 +634,8 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
|
||||
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | ✅ |
|
||||
| **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é par la clé racine, signatures détachées, vérification côté joueur) | recommandée |
|
||||
| **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.
|
||||
|
||||
@@ -28,7 +28,9 @@ public static class ApiModules
|
||||
new Modules.Audit.AuditModule(),
|
||||
new Modules.Catalog.CatalogModule(),
|
||||
new Modules.Changelog.ChangelogModule(),
|
||||
new Modules.Publishing.PublishingModule()
|
||||
new Modules.Publishing.PublishingModule(),
|
||||
new Modules.Signing.SigningModule(),
|
||||
new Modules.Files.FilesModule()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ public sealed class LauncherApiOptions
|
||||
|
||||
public CatalogSettings Catalog { get; set; } = new();
|
||||
|
||||
public KeyringSettings Keyring { get; set; } = new();
|
||||
|
||||
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.
|
||||
@@ -76,6 +82,43 @@ public sealed class LauncherApiOptions
|
||||
public string PublicUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau (phase 7) : clés autorisées à signer le contenu, signé par la clé racine HORS LIGNE puis publié
|
||||
/// depuis la page « Signature » du menu d'administration.
|
||||
/// </summary>
|
||||
public sealed class KeyringSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Fichier public (chemin absolu .json). Vide : « keyring.json » dans le dossier du catalogue (déjà autorisé
|
||||
/// en écriture et servi par nginx), si le catalogue est configuré ; sinon publication désactivée.
|
||||
/// </summary>
|
||||
public string PublicFile { get; set; } = "";
|
||||
|
||||
/// <summary>Adresse publique (information, « keyringUrl » de launcher.json). Vide : à côté du catalogue.</summary>
|
||||
public string PublicUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Signature du contenu servi aux joueurs (phase 7).</summary>
|
||||
public sealed class ContentSigningSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Vrai : catalogue et versions ne sont publiés que signés (refus sinon). À activer une fois le trousseau
|
||||
/// publié et les launchers en mode « require » : évite de mettre en ligne un contenu que ces launchers refuseraient.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
@@ -132,6 +175,15 @@ public sealed class LauncherApiOptions
|
||||
/// </summary>
|
||||
public string CatalogImageHost { get; private set; } = "";
|
||||
|
||||
/// <summary>Fichier public du trousseau en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
|
||||
public string? KeyringPublicFileFullPath { get; private set; }
|
||||
|
||||
/// <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);
|
||||
@@ -163,7 +215,9 @@ public sealed class LauncherApiOptions
|
||||
TrustedProxyAddresses = proxies;
|
||||
|
||||
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)");
|
||||
@@ -209,6 +263,44 @@ public sealed class LauncherApiOptions
|
||||
CatalogImageHost = new Uri(CatalogPublicUrl).Host;
|
||||
}
|
||||
|
||||
private void ValidateKeyring()
|
||||
{
|
||||
KeyringPublicFileFullPath = null;
|
||||
if (!string.IsNullOrWhiteSpace(Keyring.PublicFile))
|
||||
{
|
||||
var file = Keyring.PublicFile.Trim();
|
||||
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être un chemin absolu vers un fichier .json.");
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicFile : le dossier « {directory} » n'existe pas.");
|
||||
EnsureSeparatedFromData(directory, "Keyring:PublicFile");
|
||||
KeyringPublicFileFullPath = full;
|
||||
}
|
||||
else if (CatalogPublicFileFullPath is { } catalog)
|
||||
{
|
||||
KeyringPublicFileFullPath = Path.Combine(Path.GetDirectoryName(catalog)!, "keyring.json");
|
||||
}
|
||||
if (KeyringPublicFileFullPath is { } keyring && CatalogPublicFileFullPath is { } catalogFile &&
|
||||
string.Equals(keyring, catalogFile, StringComparison.Ordinal))
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicFile doit être différent du fichier du catalogue.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Keyring.PublicUrl))
|
||||
{
|
||||
KeyringPublicUrl = new Uri(new Uri(CatalogPublicUrl), "keyring.json").AbsoluteUri;
|
||||
}
|
||||
else if (Uri.TryCreate(Keyring.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
|
||||
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
|
||||
{
|
||||
KeyringPublicUrl = url.AbsoluteUri;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicUrl doit être une adresse https://.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateGames()
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -233,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).
|
||||
|
||||
@@ -333,6 +333,7 @@ public sealed class AdminsModule : IApiModule
|
||||
Status = k.Status,
|
||||
// Clé en attente : empreinte volontairement masquée (l'approbateur doit la recevoir de la personne invitée).
|
||||
Fingerprint = k.Status == AdminStatus.Pending ? null : AdminKeys.ComputeFingerprint(k.PublicKeyBytes()),
|
||||
PublicKey = k.Status == AdminStatus.Active ? Convert.ToBase64String(k.PublicKeyBytes()) : null,
|
||||
AddedAt = k.AddedAt,
|
||||
LastUsedAt = k.LastUsedAt
|
||||
}).ToList()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
@@ -50,6 +51,20 @@ public sealed class CatalogModule : IApiModule
|
||||
if (errors.Count > 0)
|
||||
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
|
||||
// Signature (phase 7) : sur le catalogue tel qu'il sera publié (après contrôle), par la clé de cette session.
|
||||
var actor = context.Admin();
|
||||
if (request.Catalog.Signature is { } signature)
|
||||
{
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.Catalog, ContentKinds.CatalogId,
|
||||
ContentSigning.CatalogDigest(catalog), actor, time.GetUtcNow());
|
||||
catalog = catalog with { Signature = signature };
|
||||
}
|
||||
else if (options.Value.ContentSigning.Require)
|
||||
{
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
|
||||
"Le serveur exige un catalogue signé (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
|
||||
}
|
||||
|
||||
CatalogDocument published, previous;
|
||||
try
|
||||
{
|
||||
@@ -62,9 +77,9 @@ public sealed class CatalogModule : IApiModule
|
||||
"Le serveur n'a pas pu écrire le catalogue public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
var actor = context.Admin();
|
||||
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", Describe(previous, published));
|
||||
$"révision {published.Revision}",
|
||||
Describe(previous, published) + (published.Signature is null ? " ; non signé" : $" ; signé (clé {published.Signature.KeyId})"));
|
||||
return Results.Json(State(published, store, options.Value), ApiJson.Options);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)";
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Publishing;
|
||||
|
||||
@@ -81,14 +84,36 @@ public sealed class PublishingModule : IApiModule
|
||||
private static IResult UploadObjects(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () => Results.Json(store.AddObjects(id, context.Admin().Admin.Id, context.CapturedBody()), ApiJson.Options));
|
||||
|
||||
private static IResult Commit(string id, HttpContext context, PublicationStore store, AuditLog audit, ILogger<PublishingModule> logger) =>
|
||||
private static IResult Commit(string id, HttpContext context, PublicationStore store, AuditLog audit, TimeProvider time,
|
||||
IOptions<LauncherApiOptions> options, ILogger<PublishingModule> logger) =>
|
||||
Guard(logger, () =>
|
||||
{
|
||||
var (result, record) = store.Commit(id, context.Admin().Admin.Id);
|
||||
// Corps facultatif : signature du contenu du manifest (phase 7). Sans corps : mise en ligne non signée.
|
||||
PublicationCommitRequest? request = null;
|
||||
var body = context.CapturedBody();
|
||||
if (body.Length > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
request = JsonSerializer.Deserialize<PublicationCommitRequest>(body, ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ApiResults.BadRequest("Requête de mise en ligne illisible.");
|
||||
}
|
||||
}
|
||||
var signature = request?.Signature;
|
||||
if (signature is null && options.Value.ContentSigning.Require)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired,
|
||||
"Le serveur exige une version signée (LauncherApi:ContentSigning:Require) : mettez à jour le launcher.");
|
||||
var actor = context.Admin();
|
||||
var (result, record) = store.Commit(id, actor.Admin.Id, signature,
|
||||
(gameId, digest) => ContentSignatures.RequireFromActor(signature!, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()));
|
||||
if (record is null) return Results.Json(result, ApiJson.Options); // déjà en ligne (réponse précédente perdue)
|
||||
Audit(audit, context, "release.publish", result.GameId, result.Version,
|
||||
$"version précédente : {Display(result.PreviousVersion)} ; {result.ObjectsAdded} fichier(s) ajouté(s)" +
|
||||
(result.ArchiveAdded ? ", archive ajoutée" : "") + $" ; manifest {result.ManifestSha256[..12]}");
|
||||
(result.ArchiveAdded ? ", archive ajoutée" : "") + $" ; manifest {result.ManifestSha256[..12]}" +
|
||||
(signature is null ? " ; non signée" : $" ; signée (clé {signature.KeyId})"));
|
||||
return Results.Json(result, ApiJson.Options);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Signature du contenu servi aux joueurs (phase 7) :
|
||||
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests et du catalogue ;
|
||||
/// • PUT v1/keyring (admins.manage, requête signée) : publication d'un trousseau signé HORS LIGNE par la clé racine ;
|
||||
/// • GET / POST v1/games/{gameId}/signature (releases.publish) : signature de la version déjà en ligne.
|
||||
/// La signature des nouvelles versions et du catalogue se fait à leur publication (modules Publishing et Catalog).
|
||||
/// </summary>
|
||||
public sealed class SigningModule : IApiModule
|
||||
{
|
||||
public string Name => "Signing";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<KeyringStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Signing, State).RequirePermission(AdminPermissions.AdminsManage);
|
||||
api.MapPut(ApiRoutes.Keyring, PublishKeyring).RequirePermission(AdminPermissions.AdminsManage).RequireSignedRequest();
|
||||
api.MapGet(ApiRoutes.ManifestSignaturePattern, GetManifest).RequirePermission(AdminPermissions.ReleasesPublish);
|
||||
api.MapPost(ApiRoutes.ManifestSignaturePattern, SignManifest).RequirePermission(AdminPermissions.ReleasesPublish).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, AdminStore admins, IOptions<LauncherApiOptions> options,
|
||||
ILogger<SigningModule> logger)
|
||||
{
|
||||
var settings = options.Value;
|
||||
var db = admins.Read();
|
||||
var manifests = new List<ManifestSignatureState>();
|
||||
foreach (var (gameId, path) in settings.GameManifestFiles.OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
manifests.Add(ManifestSignatures.Read(gameId, path, db));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException or
|
||||
FormatException or InvalidOperationException)
|
||||
{
|
||||
// Manifest illisible (fichier modifié à la main…) : affiché « non signé », la page reste utilisable.
|
||||
logger.LogWarning(ex, "Signature du manifest de {GameId}", gameId);
|
||||
manifests.Add(new ManifestSignatureState { GameId = gameId });
|
||||
}
|
||||
}
|
||||
|
||||
CatalogSignatureState? catalog = null;
|
||||
if (catalogs.PublishingEnabled)
|
||||
{
|
||||
CatalogDocument? published;
|
||||
try
|
||||
{
|
||||
published = catalogs.ReadPublished();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
logger.LogWarning(ex, "Signature du catalogue");
|
||||
published = null;
|
||||
}
|
||||
catalog = published is null
|
||||
? new CatalogSignatureState()
|
||||
: new CatalogSignatureState
|
||||
{
|
||||
Revision = published.Revision,
|
||||
Digest = ContentSigning.CatalogDigest(published),
|
||||
Signature = published.Signature,
|
||||
SignerName = ContentSignatures.SignerName(db, published.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
keyring = keyrings.Read();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
// Trousseau de référence illisible : la page propose d'en publier un nouveau (numéro choisi par le serveur au contrôle).
|
||||
logger.LogWarning(ex, "Trousseau de référence illisible");
|
||||
keyring = null;
|
||||
}
|
||||
|
||||
return Results.Json(new SigningState
|
||||
{
|
||||
Keyring = keyring,
|
||||
KeyringPublishingEnabled = keyrings.PublishingEnabled,
|
||||
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
|
||||
RequireSignatures = settings.ContentSigning.Require,
|
||||
Manifests = manifests,
|
||||
Catalog = catalog
|
||||
}, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult PublishKeyring(HttpContext context, KeyringStore keyrings, AuditLog audit, ILogger<SigningModule> logger)
|
||||
{
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
keyring = Keyring.Parse(context.CapturedBody());
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ApiResults.BadRequest($"Trousseau illisible : {ex.Message}");
|
||||
}
|
||||
if (keyring is null) return ApiResults.BadRequest("Trousseau absent.");
|
||||
|
||||
KeyringDocument? previous;
|
||||
try
|
||||
{
|
||||
previous = keyrings.Publish(keyring);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Publication du trousseau");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le trousseau public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or JsonException)
|
||||
{
|
||||
// Trousseau de référence du serveur illisible : refus (le numéro du trousseau publié ne peut pas être contrôlé).
|
||||
logger.LogError(ex, "Trousseau de référence illisible");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le trousseau de référence du serveur est illisible : restaurez-le depuis l'historique (keyring-history/) avant de publier.");
|
||||
}
|
||||
|
||||
var actor = context.Admin();
|
||||
var added = keyring.Keys.Select(k => k.KeyId).Except(previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).ToList();
|
||||
var removed = (previous?.Keys.Select(k => k.KeyId) ?? Array.Empty<string>()).Except(keyring.Keys.Select(k => k.KeyId)).ToList();
|
||||
audit.Write("keyring.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"trousseau n° {keyring.Sequence}",
|
||||
$"{keyring.Keys.Count} clé(s), racine {keyring.Signature?.RootKeyId}" +
|
||||
(added.Count > 0 ? $" ; ajoutées : {string.Join(", ", added)}" : "") +
|
||||
(removed.Count > 0 ? $" ; retirées : {string.Join(", ", removed)}" : ""));
|
||||
return Results.Json(keyring, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult GetManifest(string gameId, AdminStore admins, IOptions<LauncherApiOptions> options)
|
||||
{
|
||||
var path = ManifestPath(gameId, options.Value);
|
||||
return Results.Json(ManifestSignatures.Read(gameId, path, admins.Read()), ApiJson.Options);
|
||||
}
|
||||
|
||||
private static IResult SignManifest(string gameId, ManifestSignRequest request, HttpContext context, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
|
||||
{
|
||||
var path = ManifestPath(gameId, options.Value);
|
||||
if (request.Signature is null || !PublishingSha(request.Digest)) return ApiResults.BadRequest("Signature ou condensé absent.");
|
||||
var actor = context.Admin();
|
||||
ManifestSignatureState state;
|
||||
try
|
||||
{
|
||||
state = ManifestSignatures.Sign(gameId, path, options.Value.DataDirectoryFullPath, request,
|
||||
digest => ContentSignatures.RequireFromActor(request.Signature, ContentKinds.Manifest, gameId, digest, actor, time.GetUtcNow()),
|
||||
admins.Read());
|
||||
}
|
||||
catch (ManifestWriteRefusedException ex)
|
||||
{
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Signature refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
logger.LogError(ex, "Signature du manifest de {GameId}", gameId);
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu réécrire le manifest : vérifiez les droits du dossier du jeu (server/deploy/LISEZMOI.md, étape 3 ter).");
|
||||
}
|
||||
audit.Write("release.sign", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"{gameId} {state.Version}", $"version en ligne signée (contenu {state.Digest[..12]})");
|
||||
return Results.Json(state, ApiJson.Options);
|
||||
}
|
||||
|
||||
private static bool PublishingSha(string? value) => Contracts.Publishing.PublishingLimits.IsValidSha256(value);
|
||||
|
||||
private static string ManifestPath(string gameId, LauncherApiOptions options)
|
||||
{
|
||||
if (!CatalogRules.IsValidGameId(gameId)) throw ApiRejection.BadRequest("Identifiant de jeu invalide.");
|
||||
return options.GameManifestFiles.TryGetValue(gameId, out var path)
|
||||
? path
|
||||
: throw new ApiRejection(404, ApiErrorCodes.NotConfigured,
|
||||
"Jeu non configuré sur le serveur (LauncherApi:Games, voir server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle, par le serveur, des signatures de contenu envoyées par le menu d'administration (phase 7). Le
|
||||
/// serveur n'est pas l'ancre de confiance (ce sont les launchers des joueurs, avec le trousseau) : ce contrôle
|
||||
/// évite seulement de mettre en ligne une signature que les launchers refuseraient, et garantit que la clé qui
|
||||
/// signe est celle de la session (rien n'est signé au nom d'un autre).
|
||||
/// </summary>
|
||||
public static class ContentSignatures
|
||||
{
|
||||
/// <exception cref="ApiRejection">400 : signature d'une autre clé, date hors tolérance, ou signature invalide.</exception>
|
||||
public static void RequireFromActor(ContentSignature signature, string kind, string id, string digest,
|
||||
AuthenticatedAdmin actor, DateTimeOffset now)
|
||||
{
|
||||
if (!string.Equals(signature.KeyId, actor.Key.KeyId, StringComparison.Ordinal))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid, "La signature du contenu doit être faite par la clé de cette session.");
|
||||
if (signature.SignedAt < now - ContentSigning.MaxSignedAtSkew || signature.SignedAt > now + ContentSigning.MaxSignedAtSkew)
|
||||
throw new ApiRejection(400, ApiErrorCodes.ClockSkew,
|
||||
"Date de signature du contenu trop éloignée de l'heure du serveur : vérifiez l'heure de Windows, puis recommencez.");
|
||||
if (!ContentSigning.VerifyWithKey(kind, id, digest, signature, actor.Key.PublicKeyBytes()))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureInvalid,
|
||||
"Signature du contenu invalide : le contenu signé ne correspond pas à celui envoyé.");
|
||||
}
|
||||
|
||||
/// <summary>« Nicolas (PC fixe) » pour une clé connue du serveur, sinon null.</summary>
|
||||
public static string? SignerName(AdminDatabase db, ContentSignature? signature)
|
||||
{
|
||||
if (signature is null || db.FindKey(signature.KeyId) is not { } found) return null;
|
||||
return string.IsNullOrWhiteSpace(found.Key.Label) ? found.Admin.DisplayName : $"{found.Admin.DisplayName} ({found.Key.Label})";
|
||||
}
|
||||
|
||||
/// <summary>Signature présente dans un manifest publié (null : absente ou illisible).</summary>
|
||||
public static ContentSignature? ReadSignature(JsonObject manifest)
|
||||
{
|
||||
if (PublishedFiles.Property(manifest, "signature") is not JsonObject node) return null;
|
||||
try
|
||||
{
|
||||
return node.Deserialize<ContentSignature>(ApiJson.Options);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -23,8 +23,6 @@ public sealed class CatalogStore
|
||||
|
||||
private static readonly JsonSerializerOptions Json = CatalogRules.PublishedJson;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
private const UnixFileMode PublicFileMode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
@@ -57,6 +55,14 @@ public sealed class CatalogStore
|
||||
return CatalogRules.Normalize(document, imageHost: null, errors) with { Revision = document.Revision };
|
||||
}
|
||||
|
||||
/// <summary>Catalogue publié tel qu'écrit (sans normalisation : signature comprise), ou null s'il n'a jamais été publié.</summary>
|
||||
public CatalogDocument? ReadPublished()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
return JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(_path), Json)
|
||||
?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publie un catalogue déjà contrôlé (<see cref="CatalogRules.Normalize"/>) : révision suivante,
|
||||
/// fichier public, puis référence privée et historique.
|
||||
@@ -110,37 +116,7 @@ public sealed class CatalogStore
|
||||
}
|
||||
}
|
||||
|
||||
private void WritePublic(string json)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_publicFile!)!;
|
||||
var temp = Path.Combine(directory, $".{Path.GetFileName(_publicFile)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PublicFileMode;
|
||||
using (var stream = new FileStream(temp, options))
|
||||
{
|
||||
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json));
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
|
||||
// pour que nginx puisse lire le fichier (lecture seule pour tous, écriture par le service seul).
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, PublicFileMode);
|
||||
File.Move(temp, _publicFile!, overwrite: true); // renommage atomique dans le même dossier
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temp);
|
||||
}
|
||||
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// fichier temporaire laissé : sans conséquence (nom unique)
|
||||
}
|
||||
throw new IOException($"Écriture du catalogue public impossible ({_publicFile}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
private void WritePublic(string json) => PublicFileWriter.Write(_publicFile!, json, "catalogue public");
|
||||
|
||||
private void RestorePrivate(string? previousText)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau publié (phase 7).
|
||||
/// • Référence : keyring.json dans le dossier des données privées (+ historique).
|
||||
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Keyring:PublicFile, par défaut à côté
|
||||
/// du catalogue), écrite atomiquement, lisible par tous.
|
||||
/// • Le serveur ne détient pas la clé racine et ne vérifie que la structure et le numéro (toujours croissant) :
|
||||
/// c'est le launcher qui vérifie la signature racine (avant l'envoi dans le menu d'administration, puis chez
|
||||
/// chaque joueur). Un trousseau falsifié serait refusé par tous les launchers.
|
||||
/// </summary>
|
||||
public sealed class KeyringStore
|
||||
{
|
||||
public const string FileName = "keyring.json";
|
||||
private const string HistoryFolder = "keyring-history";
|
||||
private const int HistoryLength = 20;
|
||||
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string _path;
|
||||
private readonly string _lockPath;
|
||||
private readonly string _historyDirectory;
|
||||
private readonly string? _publicFile;
|
||||
private readonly object _writeLock = new();
|
||||
|
||||
public KeyringStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.KeyringPublicFileFullPath) { }
|
||||
|
||||
public KeyringStore(string dataDirectory, string? publicFile)
|
||||
{
|
||||
SecureFiles.EnsureDirectory(dataDirectory);
|
||||
_path = Path.Combine(dataDirectory, FileName);
|
||||
_lockPath = _path + ".lock";
|
||||
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
|
||||
_publicFile = publicFile;
|
||||
}
|
||||
|
||||
public bool PublishingEnabled => _publicFile is not null;
|
||||
|
||||
/// <summary>Trousseau publié, ou null.</summary>
|
||||
public KeyringDocument? Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
return Keyring.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
|
||||
/// <summary>Publie un trousseau signé (structure contrôlée, numéro supérieur au trousseau actuel).</summary>
|
||||
/// <returns>Le trousseau précédent (null : premier).</returns>
|
||||
public KeyringDocument? Publish(KeyringDocument keyring)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou LauncherApi:Catalog:PublicFile).");
|
||||
var errors = new List<string>();
|
||||
if (!Keyring.Validate(keyring, errors)) throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
|
||||
if (keyring.Signature is null || string.IsNullOrEmpty(keyring.Signature.Value))
|
||||
throw ApiRejection.BadRequest("Trousseau non signé : signez-le d'abord avec la clé racine (tools\\sign-keyring.bat).");
|
||||
|
||||
lock (_writeLock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var current = Read();
|
||||
if (current is not null && keyring.Sequence <= current.Sequence)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Trousseau n° {keyring.Sequence} refusé : le trousseau publié porte déjà le n° {current.Sequence} " +
|
||||
"(exportez une nouvelle demande, qui prend le numéro suivant).", ApiErrorCodes.RevisionConflict);
|
||||
var json = Keyring.Serialize(keyring);
|
||||
if (System.Text.Encoding.UTF8.GetByteCount(json) > Keyring.MaxBytes)
|
||||
throw ApiRejection.BadRequest($"Trousseau trop volumineux (au plus {Keyring.MaxBytes / 1024} Ko).");
|
||||
|
||||
// Public d'abord : en cas d'échec, la référence privée (qui porte le numéro) reste l'ancienne.
|
||||
PublicFileWriter.Write(_publicFile, json, "trousseau public");
|
||||
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
|
||||
WriteHistory(keyring.Sequence, json);
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteHistory(long sequence, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureFiles.EnsureDirectory(_historyDirectory);
|
||||
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"keyring-{sequence:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "keyring-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
|
||||
File.Delete(old);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// L'historique est une aide : son échec n'annule pas la publication.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Signature du manifest déjà en ligne d'un jeu (version publiée avant la phase 7, ou avec WinSCP) : le launcher
|
||||
/// envoie la signature du contenu de SON manifest.json (dossier de publication) ; le serveur ne l'ajoute que si la
|
||||
/// version en ligne a exactement ce contenu, puis réécrit le manifest atomiquement (mêmes règles que les notes).
|
||||
/// </summary>
|
||||
public static class ManifestSignatures
|
||||
{
|
||||
public static ManifestSignatureState Read(string gameId, string manifestPath, AdminDatabase admins)
|
||||
{
|
||||
if (PublishedFiles.ReadManifest(manifestPath) is not { } bytes) return new ManifestSignatureState { GameId = gameId };
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
var signature = ContentSignatures.ReadSignature(root);
|
||||
return new ManifestSignatureState
|
||||
{
|
||||
GameId = gameId,
|
||||
Version = PublishedFiles.StringValue(root, "version") ?? "",
|
||||
Digest = Digest(bytes),
|
||||
Signature = signature,
|
||||
SignerName = ContentSignatures.SignerName(admins, signature)
|
||||
};
|
||||
}
|
||||
|
||||
/// <param name="verify">Contrôle de la signature sur le condensé de la version en ligne (lève une ApiRejection).</param>
|
||||
public static ManifestSignatureState Sign(string gameId, string manifestPath, string dataDirectory, ManifestSignRequest request,
|
||||
Action<string> verify, AdminDatabase admins)
|
||||
{
|
||||
using (PublishedFiles.LockGame(dataDirectory, gameId))
|
||||
{
|
||||
var bytes = PublishedFiles.ReadManifest(manifestPath)
|
||||
?? throw ApiRejection.NotFound("Aucune version en ligne pour ce jeu.");
|
||||
var digest = Digest(bytes);
|
||||
if (!string.Equals(digest, request.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
throw ApiRejection.Conflict(
|
||||
"La version en ligne ne correspond pas au manifest.json choisi (fichiers, archive ou exécutable différents) : " +
|
||||
"choisissez le dossier de publication de la version en ligne.", ApiErrorCodes.RevisionConflict);
|
||||
verify(digest);
|
||||
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
|
||||
PublishedFiles.SetProperty(root, "signature", JsonSerializer.SerializeToNode(request.Signature, ApiJson.Options));
|
||||
var newBytes = PublishedFiles.Serialize(root);
|
||||
PublishedFiles.Backup(dataDirectory, gameId, bytes);
|
||||
PublishedFiles.WriteManifest(manifestPath, newBytes, PublishedFiles.Sha256(bytes),
|
||||
() => ApiRejection.Conflict("Le manifest a été modifié pendant la signature : recommencez.", ApiErrorCodes.RevisionConflict));
|
||||
return Read(gameId, manifestPath, admins);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Digest(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ContentSigning.ManifestDigest(PublishedFiles.StripBom(bytes));
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"Manifest en ligne illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Fichier PUBLIC servi par nginx (catalogue, trousseau) : écrit atomiquement (fichier temporaire caché du
|
||||
/// même dossier, puis renommage), lisible par tous (0644), modifiable par le service seul.
|
||||
/// </summary>
|
||||
internal static class PublicFileWriter
|
||||
{
|
||||
public const UnixFileMode Mode =
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
|
||||
|
||||
/// <param name="what">Nom du fichier dans les messages (« catalogue public »…).</param>
|
||||
/// <exception cref="IOException">Écriture impossible (le fichier précédent est intact).</exception>
|
||||
public static void Write(string path, string text, string what)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path)!;
|
||||
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
|
||||
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = Mode;
|
||||
using (var stream = new FileStream(temp, options))
|
||||
{
|
||||
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(text));
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
|
||||
// pour que nginx puisse lire le fichier (lecture seule pour tous, écriture par le service seul).
|
||||
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, Mode);
|
||||
File.Move(temp, path, overwrite: true); // renommage atomique dans le même dossier
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temp);
|
||||
}
|
||||
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// fichier temporaire laissé : sans conséquence (nom unique)
|
||||
}
|
||||
throw new IOException($"Écriture du {what} impossible ({path}) : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -109,7 +110,7 @@ public sealed class PublicationStore
|
||||
AdminName = adminName,
|
||||
CreatedAt = _time.GetUtcNow(),
|
||||
PublishedVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "",
|
||||
PublishedContentSha256 = ContentSignature(currentRoot)
|
||||
PublishedContentSha256 = OnlineContentHash(currentRoot)
|
||||
};
|
||||
CreateStaging(record);
|
||||
Save(record);
|
||||
@@ -238,7 +239,10 @@ public sealed class PublicationStore
|
||||
/// Déjà faite (réponse précédente perdue) : le même résultat est renvoyé.
|
||||
/// </summary>
|
||||
/// <returns>Le résultat, et l'enregistrement (null si la mise en ligne avait déjà eu lieu).</returns>
|
||||
public (PublicationResult Result, PublicationRecord? Record) Commit(string id, Guid adminId)
|
||||
/// <param name="signature">Signature du contenu du manifest par l'administrateur (phase 7), ou null.</param>
|
||||
/// <param name="verifySignature">Contrôle de la signature sur le condensé du manifest reçu (lève une ApiRejection).</param>
|
||||
public (PublicationResult Result, PublicationRecord? Record) Commit(string id, Guid adminId, ContentSignature? signature = null,
|
||||
Action<string, string>? verifySignature = null)
|
||||
{
|
||||
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
|
||||
if (Load(id) is null && LoadDone(id) is { } done && done.AdminId == adminId) return (done.Result, null);
|
||||
@@ -268,14 +272,21 @@ public sealed class PublicationStore
|
||||
var currentVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "";
|
||||
// Fichiers en ligne changés pendant l'envoi (autre publication, WinSCP) : jamais écrasés. Une
|
||||
// correction des notes (page Notes de version) ne change que « changelog » : elle est permise.
|
||||
if (ContentSignature(currentRoot) != record.PublishedContentSha256)
|
||||
if (OnlineContentHash(currentRoot) != record.PublishedContentSha256)
|
||||
throw ApiRejection.Conflict(
|
||||
$"La version en ligne a changé pendant l'envoi ({Display(record.PublishedVersion)} → {Display(currentVersion)}) : " +
|
||||
"vérifiez avant de publier la vôtre, puis recommencez.", ApiErrorCodes.RevisionConflict);
|
||||
|
||||
// Nouveau manifest : celui reçu, avec l'historique des notes du serveur (corrections en ligne).
|
||||
// Signature (phase 7) : sur le contenu reçu (fichiers, archive, exécutable…), que la fusion des notes ne touche pas.
|
||||
if (signature is not null)
|
||||
verifySignature?.Invoke(gameId, ContentSigning.ManifestDigest(PublishedFiles.StripBom(manifestBytes)));
|
||||
|
||||
// Nouveau manifest : celui reçu, avec l'historique des notes du serveur (corrections en ligne) et la
|
||||
// signature de cette mise en ligne (une signature éventuellement présente dans le fichier reçu est retirée).
|
||||
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(manifestBytes), "Manifest");
|
||||
PublishedFiles.SetProperty(root, "changelog", MergeChangelog(root, currentRoot, record.Version));
|
||||
PublishedFiles.SetProperty(root, "signature",
|
||||
signature is null ? null : JsonSerializer.SerializeToNode(signature, Contracts.ApiJson.Options));
|
||||
var newBytes = PublishedFiles.Serialize(root);
|
||||
|
||||
// 0. Contrôles AVANT tout déplacement : droits, liens symboliques, dossiers objects/xx.
|
||||
@@ -395,12 +406,13 @@ public sealed class PublicationStore
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>Empreinte du manifest en ligne SANS les notes (fichiers, archive, version…) ; vide s'il n'existe pas.</summary>
|
||||
private static string ContentSignature(JsonObject? root)
|
||||
/// <summary>Empreinte de la version en ligne hors notes et hors signature (une correction de notes ou une signature reste permise pendant l'envoi) ; vide si elle n'existe pas.</summary>
|
||||
private static string OnlineContentHash(JsonObject? root)
|
||||
{
|
||||
if (root is null) return "";
|
||||
var copy = root.DeepClone().AsObject();
|
||||
PublishedFiles.SetProperty(copy, "changelog", null);
|
||||
PublishedFiles.SetProperty(copy, "signature", null);
|
||||
return PublishedFiles.Sha256(System.Text.Encoding.UTF8.GetBytes(copy.ToJsonString()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,26 @@
|
||||
"PublicFile": "",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
// Trousseau (phase 7) : vide = « keyring.json » à côté du catalogue (même dossier, déjà autorisé en écriture).
|
||||
"Keyring": {
|
||||
"PublicFile": "",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
// Signature du contenu : true = catalogue et versions refusés s'ils ne sont pas signés (une fois les
|
||||
// launchers en mode « require », voir server/deploy/LISEZMOI.md, étape 3 quinquies).
|
||||
"ContentSigning": {
|
||||
"Require": false
|
||||
},
|
||||
// Notes de version modifiables en ligne (phase 4) : manifest.json publié de chaque jeu, par
|
||||
// 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.
|
||||
|
||||
@@ -227,6 +227,104 @@ Une publication interrompue (fermeture du launcher, coupure) reprend quand tu cl
|
||||
possible, mais n'envoie pas une version avec les deux en même temps : le serveur refuse alors de
|
||||
mettre la sienne en ligne plutôt que d'écraser l'autre.
|
||||
|
||||
## 3 quinquies. Signer ce que reçoivent les joueurs (PC et VPS)
|
||||
|
||||
Sans signature, quelqu'un qui prendrait le contrôle du VPS pourrait distribuer un faux jeu. Avec la
|
||||
signature, chaque version mise en ligne (fichiers, archive, exécutable) et le catalogue portent la
|
||||
signature de l'administrateur qui les a publiés. Les launchers ne font confiance qu'aux clés du
|
||||
**trousseau**, lui-même signé **hors ligne** par la clé racine du launcher (`launcher-signing.key`, celle des
|
||||
mises à jour du launcher). Le VPS ne détient aucune de ces clés privées.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue : le trousseau est écrit dans le même dossier) et 3 quater.
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le trousseau
|
||||
est écrit à côté du catalogue (`/srv/exovival/www/catalog/keyring.json`).
|
||||
2. **nginx** : ajoute le bloc `location = /catalog/keyring.json { … }` de `server/deploy/nginx-catalog.conf` à côté
|
||||
de celui du catalogue, puis `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
3. **Trousseau** (PC) :
|
||||
1. menu d'administration › **Signature** › « Exporter la demande… » : enregistre `keyring-request-1.json`
|
||||
(les appareils actifs des administrateurs autorisés à publier) ;
|
||||
2. glisse ce fichier sur `tools\sign-keyring.bat` : la demande est comparée au trousseau signé précédent
|
||||
(`keyring-N.json` du même dossier) et chaque **NOUVELLE CLÉ** (ou autorisation élargie) est signalée.
|
||||
**Vérifie ces empreintes hors du serveur** : la demande vient du VPS, un serveur compromis pourrait y glisser
|
||||
sa propre clé. Fais lire à chaque personne l'empreinte de son appareil (page **Mes appareils**, calculée sur
|
||||
son PC) de vive voix ou par téléphone, et compare. Puis la phrase de passe de la clé racine → `keyring-N.json`
|
||||
(garde ce fichier dans le même dossier : il servira de référence la fois suivante) ;
|
||||
3. page **Signature** › « Publier le trousseau signé… » : le launcher vérifie la signature racine avant de l'envoyer.
|
||||
|
||||
Les noms des appareils (« Nicolas — PC fixe ») sont **publics** : le trousseau est lisible par tous.
|
||||
4. **Contenu déjà en ligne** :
|
||||
* version du jeu : page **Publier une version** › « Choisir le manifest.json… » du dossier de **la version
|
||||
en ligne** › « Signer la version en ligne » (le serveur vérifie que les fichiers sont identiques) ;
|
||||
* catalogue : page **Jeux** › « Publier » (il est signé à chaque publication).
|
||||
|
||||
La page **Signature** doit alors tout indiquer « valide pour les launchers ».
|
||||
5. **Launcher** : dans `src\GameLauncher\launcher.json`, décommente `"keyringUrl"` (adresse affichée par la page
|
||||
Signature) et `"contentSigning": "warn"`, puis publie le launcher (mise à jour signée habituelle). En « warn »,
|
||||
les problèmes sont seulement inscrits au journal du launcher (`%AppData%\<éditeur>\GameLauncher\logs\launcher.log`) : vérifie qu'il
|
||||
indique « Manifest de EZR … signé par … » et « Trousseau n° 1 vérifié ».
|
||||
6. **Protection complète** : passe à `"contentSigning": "require"` et publie le launcher : un contenu non signé,
|
||||
mal signé ou plus ancien que le dernier vu est alors refusé (le jeu installé reste jouable). Optionnel, sur le
|
||||
VPS : `"ContentSigning": { "Require": true }` dans la section `LauncherApi` d'`appsettings.Production.json`
|
||||
(puis `sudo systemctl restart launcher-api`) pour que le serveur refuse toute publication non signée.
|
||||
|
||||
Ensuite, les nouvelles versions et le catalogue sont signés automatiquement à leur publication. **À refaire
|
||||
(étape 3)** : à chaque nouvel administrateur ou nouvel appareil qui doit publier (avant sa première publication),
|
||||
et à chaque révocation (la page Signature signale le contenu signé par une clé qui va disparaître : re-signe-le
|
||||
d'abord). Une version envoyée avec WinSCP doit être signée ensuite (étape 4).
|
||||
|
||||
**Clé compromise** (appareil volé…) : révoque l'appareil, signe et publie un nouveau trousseau (étape 3), puis
|
||||
mets son numéro dans `"keyringMinSequence"` de `launcher.json` et publie le launcher. Sans cela, un launcher qui
|
||||
n'a encore jamais vu le nouveau trousseau (installation neuve, cache effacé) accepterait encore l'ancien,
|
||||
toujours signé par la clé racine, si un serveur compromis le lui présentait.
|
||||
|
||||
Limites :
|
||||
* les images du catalogue ne sont pas signées (un serveur compromis pourrait les remplacer, pas les liens ni les
|
||||
textes) ;
|
||||
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
|
||||
et au moins celui du dernier trousseau qu'il a vu ;
|
||||
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
|
||||
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
|
||||
|
||||
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
|
||||
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 :
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# =============================================================================
|
||||
# Catalogue des jeux (écrit par la Launcher API) — un ajout (voir LISEZMOI.md, étape 3 bis) :
|
||||
# Catalogue des jeux et trousseau (écrits par la Launcher API) — voir LISEZMOI.md, étapes 3 bis et 3 quinquies :
|
||||
#
|
||||
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
|
||||
# AVANT « location / { … } » : (le bloc ci-dessous)
|
||||
@@ -18,3 +18,11 @@ location = /catalog/catalog.json {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Trousseau (phase 7) : clés autorisées à signer versions et catalogue, signé par la clé racine du launcher.
|
||||
location = /catalog/keyring.json {
|
||||
limit_except GET { deny all; }
|
||||
default_type application/json;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
using System.Security.Cryptography;
|
||||
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;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.ApiClient;
|
||||
|
||||
@@ -44,6 +47,12 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
public DateTimeOffset IdleExpiresAt { get; private set; }
|
||||
public bool IsOpen => _token is not null;
|
||||
|
||||
/// <summary>Clé publique de CET appareil (SPKI), lue sur le PC : son empreinte ne dépend pas du serveur.</summary>
|
||||
public byte[] PublicKey => _signer.PublicKey;
|
||||
|
||||
/// <summary>Empreinte de la clé de cet appareil, calculée sur le PC (à dicter pour vérifier le trousseau).</summary>
|
||||
public string LocalFingerprint => AdminKeys.ComputeFingerprint(_signer.PublicKey);
|
||||
|
||||
public bool Has(string permission) => Profile.Permissions.Contains(permission, StringComparer.Ordinal);
|
||||
|
||||
// ------------------------------------------------------------------ Session
|
||||
@@ -106,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)
|
||||
|
||||
@@ -174,9 +202,38 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
PublishingLimits.ObjectBatchContentType, UploadTimeout, ct);
|
||||
|
||||
/// <summary>Mise en ligne (délai long : renommages et écriture du manifest sur le serveur).</summary>
|
||||
public Task<PublicationResult> CommitPublicationAsync(string id, CancellationToken ct = default) =>
|
||||
SendCoreAsync<PublicationResult>(HttpMethod.Post, ApiRoutes.PublicationCommit(CheckPublicationId(id)), null, signed: true, ct,
|
||||
timeout: UploadTimeout);
|
||||
/// <param name="signature">Signature du contenu du manifest (phase 7), ou null (version non signée).</param>
|
||||
public Task<PublicationResult> CommitPublicationAsync(string id, ContentSignature? signature = null, CancellationToken ct = default) =>
|
||||
SendCoreAsync<PublicationResult>(HttpMethod.Post, ApiRoutes.PublicationCommit(CheckPublicationId(id)),
|
||||
signature is null ? null : JsonSerializer.SerializeToUtf8Bytes(new PublicationCommitRequest { Signature = signature }, ApiJson.Options),
|
||||
signed: true, ct, timeout: UploadTimeout);
|
||||
|
||||
// ------------------------------------------------------------------ Signature du contenu (phase 7)
|
||||
|
||||
/// <summary>
|
||||
/// Signe un contenu servi aux joueurs avec la clé de cette session (date : heure du serveur, à la seconde).
|
||||
/// La clé privée ne quitte jamais le PC (ni la puce TPM) : seul le condensé calculé ici est signé.
|
||||
/// </summary>
|
||||
public async Task<ContentSignature> SignContentAsync(string kind, string id, string digest, CancellationToken ct = default)
|
||||
{
|
||||
var signedAt = ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset);
|
||||
var message = ContentSigning.Message(kind, id, signedAt, _signer.KeyId, digest);
|
||||
var signature = await _signer.SignAsync(message, ct).ConfigureAwait(false);
|
||||
return new ContentSignature { KeyId = _signer.KeyId, SignedAt = signedAt, Value = Convert.ToBase64String(signature) };
|
||||
}
|
||||
|
||||
public Task<SigningState> GetSigningStateAsync(CancellationToken ct = default) => GetAsync<SigningState>(ApiRoutes.Signing, ct);
|
||||
|
||||
/// <summary>Publie un trousseau signé par la clé racine (le serveur contrôle la structure et le numéro).</summary>
|
||||
public Task<KeyringDocument> PublishKeyringAsync(KeyringDocument keyring, CancellationToken ct = default) =>
|
||||
SendSignedAsync<KeyringDocument>(HttpMethod.Put, ApiRoutes.Keyring, keyring, ct);
|
||||
|
||||
public Task<ManifestSignatureState> GetManifestSignatureAsync(string gameId, CancellationToken ct = default) =>
|
||||
GetAsync<ManifestSignatureState>(ApiRoutes.ManifestSignature(CheckGameId(gameId)), ct);
|
||||
|
||||
/// <summary>Signe la version en ligne (le serveur vérifie qu'elle a exactement le contenu signé).</summary>
|
||||
public Task<ManifestSignatureState> SignOnlineManifestAsync(string gameId, ManifestSignRequest request, CancellationToken ct = default) =>
|
||||
SendSignedAsync<ManifestSignatureState>(HttpMethod.Post, ApiRoutes.ManifestSignature(CheckGameId(gameId)), request, ct);
|
||||
|
||||
private static string CheckPublicationId(string id) =>
|
||||
PublishingLimits.IsValidPublicationId(id) ? id : throw new ArgumentException("Identifiant de publication invalide.", nameof(id));
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -52,6 +79,17 @@ public static class ApiRoutes
|
||||
|
||||
public static string GameChangelogEntry(string gameId, string version) => $"v1/games/{gameId}/changelog/{version}";
|
||||
|
||||
// ------------------------------------------------------------------ Signature du contenu (phase 7)
|
||||
/// <summary>État des signatures (trousseau publié, manifests, catalogue) : propriétaires.</summary>
|
||||
public const string Signing = "v1/signing";
|
||||
|
||||
/// <summary>Publication d'un trousseau signé par la clé racine : propriétaires.</summary>
|
||||
public const string Keyring = "v1/keyring";
|
||||
|
||||
public const string ManifestSignaturePattern = "v1/games/{gameId}/signature";
|
||||
|
||||
public static string ManifestSignature(string gameId) => $"v1/games/{gameId}/signature";
|
||||
|
||||
// ------------------------------------------------------------------ Publication d'une version
|
||||
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
|
||||
public const string PublicationPattern = "v1/publications/{id}";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Contracts.Catalog;
|
||||
|
||||
/// <summary>
|
||||
@@ -23,6 +25,12 @@ public sealed record CatalogDocument
|
||||
|
||||
/// <summary>Par identifiant de jeu (GameId de launcher.json).</summary>
|
||||
public Dictionary<string, CatalogGame> Games { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Signature de l'administrateur qui a publié (phase 7) : couvre tout sauf la révision et la date
|
||||
/// (<see cref="ContentSigning.CatalogDigest"/>). Ignorée par les launchers antérieurs.
|
||||
/// </summary>
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Réglages d'affichage du launcher (liste des jeux).</summary>
|
||||
@@ -40,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; }
|
||||
|
||||
@@ -12,6 +12,12 @@ public sealed record AdminKeySummary
|
||||
/// </summary>
|
||||
public string? Fingerprint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Clé publique (SPKI, Base64) des clés ACTIVES seulement (trousseau de la phase 7). Absente pour une clé en
|
||||
/// attente (on pourrait en déduire l'empreinte) ou révoquée.
|
||||
/// </summary>
|
||||
public string? PublicKey { get; init; }
|
||||
|
||||
public DateTimeOffset AddedAt { get; init; }
|
||||
public DateTimeOffset? LastUsedAt { 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 }
|
||||
};
|
||||
|
||||
@@ -38,6 +38,28 @@ public static class SignedMessages
|
||||
("body-sha256", Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant()),
|
||||
("timestamp", timestamp.ToString(System.Globalization.CultureInfo.InvariantCulture)), ("nonce", nonce));
|
||||
|
||||
/// <summary>
|
||||
/// Signature d'un contenu servi aux joueurs (manifest d'un jeu, catalogue) par la clé d'un administrateur
|
||||
/// (phase 7). En-tête distinct : une signature de contenu ne peut pas servir de signature de requête,
|
||||
/// et inversement.
|
||||
/// </summary>
|
||||
public const string ContentHeader = "GameLauncher/Content/v1";
|
||||
|
||||
/// <summary>Signature du trousseau (clés autorisées à signer le contenu) par la clé racine du launcher.</summary>
|
||||
public const string KeyringHeader = "GameLauncher/Keyring/v1";
|
||||
|
||||
/// <param name="kind">« manifest » ou « catalog ».</param>
|
||||
/// <param name="id">Identifiant du jeu (minuscules) ou « catalog ».</param>
|
||||
/// <param name="digest">SHA-256 (hexadécimal minuscule) du contenu canonique.</param>
|
||||
public static byte[] Content(string kind, string id, long signedAt, string keyId, string digest) =>
|
||||
Build(ContentHeader,
|
||||
("kind", kind), ("id", id), ("signed-at", signedAt.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||
("key", keyId), ("digest", digest));
|
||||
|
||||
/// <param name="digest">SHA-256 (hexadécimal minuscule) du trousseau canonique (sans sa signature).</param>
|
||||
public static byte[] Keyring(string rootKeyId, string digest) =>
|
||||
Build(KeyringHeader, ("root", rootKeyId), ("digest", digest));
|
||||
|
||||
/// <summary>Empreinte d'un code d'invitation (normalisé) : seule valeur conservée par le serveur.</summary>
|
||||
public static string HashInvitationCode(string normalizedCode) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("GameLauncher/Admin/Invitation/v1\n" + normalizedCode)))
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Signature d'un contenu servi aux joueurs (propriété « signature » du manifest.json d'un jeu ou du
|
||||
/// catalog.json), produite par la clé d'un administrateur. Les anciens launchers ignorent cette propriété.
|
||||
/// </summary>
|
||||
public sealed record ContentSignature
|
||||
{
|
||||
/// <summary>Identifiant de la clé qui a signé (16 caractères hexadécimaux).</summary>
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Date de la signature (heure du serveur, à la seconde) : sert aussi à refuser un contenu plus ancien que le dernier vu.</summary>
|
||||
public DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>Signature ECDSA P-256 / SHA-256 (64 octets, IEEE P1363) en Base64.</summary>
|
||||
public string Value { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Types de contenu signés et autorisations (« scopes ») correspondantes dans le trousseau.</summary>
|
||||
public static class ContentKinds
|
||||
{
|
||||
public const string Manifest = "manifest";
|
||||
public const string Catalog = "catalog";
|
||||
|
||||
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
|
||||
public const string CatalogId = "catalog";
|
||||
|
||||
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
|
||||
public static string ScopeOf(string kind) => kind == Manifest ? SigningScopes.Releases : SigningScopes.Catalog;
|
||||
}
|
||||
|
||||
/// <summary>Autorisations d'une clé du trousseau.</summary>
|
||||
public static class SigningScopes
|
||||
{
|
||||
/// <summary>Signer le manifest d'un jeu (ce qui est installé et exécuté) : permission releases.publish.</summary>
|
||||
public const string Releases = "releases";
|
||||
|
||||
/// <summary>Signer le catalogue (noms, liens, images affichés) : permission games.edit.</summary>
|
||||
public const string Catalog = "catalog";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog };
|
||||
|
||||
/// <summary>Autorisations qu'apportent les permissions d'un administrateur.</summary>
|
||||
public static IReadOnlyList<string> FromPermissions(IEnumerable<string> permissions)
|
||||
{
|
||||
var set = permissions.ToHashSet(StringComparer.Ordinal);
|
||||
var scopes = new List<string>();
|
||||
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
|
||||
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
|
||||
return scopes;
|
||||
}
|
||||
|
||||
public static string DisplayName(string scope) => scope switch
|
||||
{
|
||||
Releases => "versions du jeu",
|
||||
Catalog => "catalogue",
|
||||
_ => scope
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partie d'un manifest couverte par la signature : tout ce qui décide de ce qui est installé et exécuté
|
||||
/// (jeu, version, exécutable, arguments, archive, fichiers et leurs empreintes). Les notes de version et la
|
||||
/// date ne le sont pas : elles restent modifiables en ligne (texte borné, jamais exécuté).
|
||||
/// </summary>
|
||||
public sealed record ManifestContent
|
||||
{
|
||||
public int FormatVersion { get; init; } = 1;
|
||||
public string GameId { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public string Executable { get; init; } = "";
|
||||
public string? LaunchArguments { get; init; }
|
||||
public ManifestContentArchive Archive { get; init; } = new();
|
||||
public string? ObjectsBaseUrl { get; init; }
|
||||
public List<ManifestContentFile> Files { get; init; } = new();
|
||||
|
||||
/// <summary>Lit un manifest.json (mêmes noms de propriétés que le launcher, casse indifférente).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static ManifestContent Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.StartsWith(Utf8Bom)) json = json[Utf8Bom.Length..];
|
||||
return JsonSerializer.Deserialize<ManifestContent>(json, ReadOptions) ?? throw new JsonException("manifest vide");
|
||||
}
|
||||
|
||||
private static ReadOnlySpan<byte> Utf8Bom => new byte[] { 0xEF, 0xBB, 0xBF };
|
||||
|
||||
private static readonly JsonSerializerOptions ReadOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
MaxDepth = 32
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record ManifestContentArchive
|
||||
{
|
||||
public string Url { get; init; } = "";
|
||||
public long Size { get; init; }
|
||||
public string Sha256 { get; init; } = "";
|
||||
}
|
||||
|
||||
public sealed record ManifestContentFile
|
||||
{
|
||||
public string Path { get; init; } = "";
|
||||
public long Size { get; init; }
|
||||
public string Sha256 { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Résultat d'une vérification de signature de contenu.</summary>
|
||||
public sealed record ContentCheck(bool Valid, string Reason, KeyringKey? Key)
|
||||
{
|
||||
public static ContentCheck Fail(string reason) => new(false, reason, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Condensés canoniques et signatures du contenu. Le même code est utilisé par le menu d'administration
|
||||
/// (qui signe), le serveur (qui contrôle avant d'écrire) et le launcher des joueurs (qui vérifie) : chacun
|
||||
/// calcule le condensé à partir de ce qu'il a lui-même lu.
|
||||
/// </summary>
|
||||
public static class ContentSigning
|
||||
{
|
||||
/// <summary>Écart toléré par le serveur entre la date de signature et son horloge.</summary>
|
||||
public static readonly TimeSpan MaxSignedAtSkew = TimeSpan.FromMinutes(10);
|
||||
|
||||
private static readonly JsonSerializerOptions Canonical = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Condensé du contenu signé d'un manifest (fichiers triés, empreintes en minuscules ; valeurs absentes = vides).</summary>
|
||||
public static string ManifestDigest(ManifestContent manifest)
|
||||
{
|
||||
var archive = manifest.Archive ?? new ManifestContentArchive();
|
||||
var canonical = new ManifestContent
|
||||
{
|
||||
FormatVersion = manifest.FormatVersion,
|
||||
GameId = (manifest.GameId ?? "").ToLowerInvariant(),
|
||||
Version = manifest.Version ?? "",
|
||||
Executable = (manifest.Executable ?? "").Replace('\\', '/'),
|
||||
LaunchArguments = string.IsNullOrEmpty(manifest.LaunchArguments) ? null : manifest.LaunchArguments,
|
||||
Archive = new ManifestContentArchive
|
||||
{
|
||||
Url = archive.Url ?? "",
|
||||
Size = archive.Size,
|
||||
Sha256 = (archive.Sha256 ?? "").ToLowerInvariant()
|
||||
},
|
||||
ObjectsBaseUrl = string.IsNullOrWhiteSpace(manifest.ObjectsBaseUrl) ? "objects/" : manifest.ObjectsBaseUrl,
|
||||
Files = (manifest.Files ?? new List<ManifestContentFile>())
|
||||
.Where(f => f is not null)
|
||||
.Select(f => new ManifestContentFile { Path = f.Path ?? "", Size = f.Size, Sha256 = (f.Sha256 ?? "").ToLowerInvariant() })
|
||||
.OrderBy(f => f.Path, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.Sha256, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>Condensé du contenu signé d'un manifest.json (octets bruts).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static string ManifestDigest(ReadOnlySpan<byte> manifestJson) => ManifestDigest(ManifestContent.Parse(manifestJson));
|
||||
|
||||
/// <summary>
|
||||
/// Condensé du contenu signé d'un catalogue : tout sauf la révision, la date et la signature (posées par le
|
||||
/// serveur) ; jeux triés par identifiant, tels qu'écrits (sans changer la casse : deux identifiants qui ne
|
||||
/// diffèrent que par la casse donneraient deux entrées distinctes, voir <see cref="HasDuplicateGames"/>).
|
||||
/// </summary>
|
||||
public static string CatalogDigest(CatalogDocument catalog)
|
||||
{
|
||||
var games = new SortedDictionary<string, CatalogGame>(StringComparer.Ordinal);
|
||||
foreach (var (id, game) in catalog.Games ?? new Dictionary<string, CatalogGame>())
|
||||
games[id] = game;
|
||||
var canonical = new CanonicalCatalog(catalog.Format, catalog.Version, catalog.Launcher, games);
|
||||
return Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identifiants de jeux en double à la casse près : le launcher n'en garderait qu'un, différent de celui
|
||||
/// couvert par la signature. Un tel catalogue est refusé par la vérification.
|
||||
/// </summary>
|
||||
public static bool HasDuplicateGames(CatalogDocument catalog) =>
|
||||
(catalog.Games?.Keys ?? Enumerable.Empty<string>()).GroupBy(k => k, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1);
|
||||
|
||||
private sealed record CanonicalCatalog(string Format, int Version, CatalogLauncher? Launcher, SortedDictionary<string, CatalogGame> Games);
|
||||
|
||||
/// <summary>Identifiant signé : jeu en minuscules, ou « catalog ».</summary>
|
||||
public static string SignedId(string kind, string id) => kind == ContentKinds.Catalog ? ContentKinds.CatalogId : id.ToLowerInvariant();
|
||||
|
||||
/// <summary>Date de signature ramenée à la seconde (UTC), comme dans le message signé.</summary>
|
||||
public static DateTimeOffset Truncate(DateTimeOffset value) => DateTimeOffset.FromUnixTimeSeconds(value.ToUnixTimeSeconds());
|
||||
|
||||
/// <summary>Message à signer par la clé de l'administrateur.</summary>
|
||||
public static byte[] Message(string kind, string id, DateTimeOffset signedAt, string keyId, string digest) =>
|
||||
SignedMessages.Content(kind, SignedId(kind, id), signedAt.ToUnixTimeSeconds(), keyId, digest);
|
||||
|
||||
/// <summary>Vérifie une signature avec une clé publique donnée (le serveur : la clé de l'administrateur qui envoie).</summary>
|
||||
public static bool VerifyWithKey(string kind, string id, string digest, ContentSignature signature, ReadOnlySpan<byte> publicKey)
|
||||
{
|
||||
if (!AdminKeys.IsValidKeyId(signature.KeyId) || signature.SignedAt == default) return false;
|
||||
if (AdminKeys.DecodeSignature(signature.Value) is not { } value) return false;
|
||||
if (!string.Equals(AdminKeys.ComputeKeyId(publicKey), signature.KeyId, StringComparison.Ordinal)) return false;
|
||||
return AdminKeys.Verify(publicKey, Message(kind, id, signature.SignedAt, signature.KeyId, digest), value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie une signature avec le trousseau (le launcher des joueurs) : clé présente, autorisée pour ce type
|
||||
/// de contenu, signature valide sur le condensé calculé localement.
|
||||
/// </summary>
|
||||
public static ContentCheck Verify(string kind, string id, string digest, ContentSignature? signature, KeyringDocument keyring)
|
||||
{
|
||||
if (signature is null) return ContentCheck.Fail("contenu non signé");
|
||||
if (!AdminKeys.IsValidKeyId(signature.KeyId)) return ContentCheck.Fail("signature mal formée");
|
||||
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, signature.KeyId, StringComparison.Ordinal));
|
||||
if (key is null) return ContentCheck.Fail($"signé par une clé absente du trousseau ({signature.KeyId})");
|
||||
if (key.Scopes is null || !key.Scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal))
|
||||
return ContentCheck.Fail($"la clé {signature.KeyId} ({key.Name}) n'est pas autorisée à signer ce contenu");
|
||||
byte[] publicKey;
|
||||
try
|
||||
{
|
||||
publicKey = Convert.FromBase64String(key.PublicKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return ContentCheck.Fail("clé du trousseau illisible");
|
||||
}
|
||||
return VerifyWithKey(kind, id, digest, signature, publicKey)
|
||||
? new ContentCheck(true, $"signé par {key.Name} le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC", key)
|
||||
: ContentCheck.Fail("SIGNATURE INVALIDE (contenu modifié après signature, ou signature d'un autre contenu)");
|
||||
}
|
||||
|
||||
internal static string Sha256(ReadOnlySpan<byte> data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau (keyring.json) : clés d'administrateurs autorisées à signer le contenu servi aux joueurs, signé
|
||||
/// par la clé RACINE (celle des mises à jour du launcher, dont la clé publique est intégrée à l'exe). La clé
|
||||
/// racine reste hors ligne (LauncherPublisher, « sign-keyring.bat ») : un serveur compromis ne peut ni
|
||||
/// ajouter une clé, ni modifier le trousseau sans que les launchers le refusent.
|
||||
/// </summary>
|
||||
public sealed record KeyringDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-keyring";
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
public string Format { get; init; } = FormatName;
|
||||
public int Version { get; init; } = CurrentVersion;
|
||||
|
||||
/// <summary>Numéro croissant : un launcher refuse un trousseau plus ancien que le dernier qu'il a vu.</summary>
|
||||
public long Sequence { get; init; }
|
||||
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
public List<KeyringKey> Keys { get; init; } = new();
|
||||
|
||||
/// <summary>Signature de la clé racine (absente dans une demande de trousseau, avant signature).</summary>
|
||||
public KeyringSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
public sealed record KeyringKey
|
||||
{
|
||||
public string KeyId { get; init; } = "";
|
||||
|
||||
/// <summary>Clé publique ECDSA P-256 (SPKI, Base64).</summary>
|
||||
public string PublicKey { get; init; } = "";
|
||||
|
||||
/// <summary>Administrateur et appareil (affichage, journal du launcher).</summary>
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
/// <summary>Ce que la clé peut signer (<see cref="SigningScopes"/>).</summary>
|
||||
public List<string> Scopes { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record KeyringSignature
|
||||
{
|
||||
/// <summary>Identifiant de la clé racine (16 caractères hexadécimaux, comme pour les mises à jour du launcher).</summary>
|
||||
public string RootKeyId { get; init; } = "";
|
||||
|
||||
public string Value { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Contrôles, condensé et signature du trousseau (même code partout).</summary>
|
||||
public static class Keyring
|
||||
{
|
||||
/// <summary>Taille maximale de keyring.json.</summary>
|
||||
public const int MaxBytes = 60 * 1024; // envoyé à l'API : sous la limite de 64 Ko des requêtes
|
||||
|
||||
public const int MaxKeys = 100;
|
||||
public const int MaxNameLength = 120;
|
||||
|
||||
private static readonly JsonSerializerOptions Canonical = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>Lit un trousseau (null : JSON vide).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static KeyringDocument? Parse(ReadOnlySpan<byte> json)
|
||||
{
|
||||
if (json.Length > MaxBytes) throw new JsonException($"trousseau trop volumineux (au plus {MaxBytes / 1024} Ko)");
|
||||
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
|
||||
return JsonSerializer.Deserialize<KeyringDocument>(json, ApiJson.Options);
|
||||
}
|
||||
|
||||
public static string Serialize(KeyringDocument keyring) =>
|
||||
JsonSerializer.Serialize(keyring, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true });
|
||||
|
||||
/// <summary>Condensé du trousseau sans sa signature (clés triées, autorisations triées).</summary>
|
||||
public static string Digest(KeyringDocument keyring)
|
||||
{
|
||||
var canonical = keyring with
|
||||
{
|
||||
Signature = null,
|
||||
IssuedAt = ContentSigning.Truncate(keyring.IssuedAt),
|
||||
Keys = (keyring.Keys ?? new List<KeyringKey>())
|
||||
.Select(k => k with { Scopes = (k.Scopes ?? new List<string>()).Distinct(StringComparer.Ordinal).OrderBy(s => s, StringComparer.Ordinal).ToList() })
|
||||
.OrderBy(k => k.KeyId, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, Canonical));
|
||||
}
|
||||
|
||||
/// <summary>Contrôle de structure (sans la signature racine). Les erreurs sont ajoutées à <paramref name="errors"/>.</summary>
|
||||
public static bool Validate(KeyringDocument keyring, List<string> errors)
|
||||
{
|
||||
var count = errors.Count;
|
||||
if (keyring.Format != KeyringDocument.FormatName || keyring.Version != KeyringDocument.CurrentVersion)
|
||||
errors.Add($"Format de trousseau non pris en charge ({keyring.Format} v{keyring.Version}).");
|
||||
if (keyring.Sequence < 1) errors.Add("Numéro du trousseau invalide (1 au moins).");
|
||||
if (keyring.IssuedAt == default) errors.Add("Date du trousseau absente.");
|
||||
if (keyring.Keys is null || keyring.Keys.Any(k => k is null || k.Scopes is null || k.PublicKey is null || k.Name is null))
|
||||
{
|
||||
errors.Add("Trousseau incomplet (clés, clé publique, nom ou autorisations absents).");
|
||||
return false;
|
||||
}
|
||||
if (keyring.Keys.Count > MaxKeys) errors.Add($"Trop de clés dans le trousseau (au plus {MaxKeys}).");
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var key in keyring.Keys)
|
||||
{
|
||||
var label = AdminKeys.IsValidKeyId(key.KeyId) ? key.KeyId : "(identifiant invalide)";
|
||||
if (!AdminKeys.IsValidKeyId(key.KeyId)) errors.Add("Clé du trousseau : identifiant invalide.");
|
||||
else if (!seen.Add(key.KeyId)) errors.Add($"Clé {label} en double.");
|
||||
try
|
||||
{
|
||||
var spki = AdminKeys.ParsePublicKey(key.PublicKey);
|
||||
if (!string.Equals(AdminKeys.ComputeKeyId(spki), key.KeyId, StringComparison.Ordinal))
|
||||
errors.Add($"Clé {label} : l'identifiant ne correspond pas à la clé publique.");
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
errors.Add($"Clé {label} : clé publique illisible ou d'un autre type que ECDSA P-256.");
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(key.Name) || key.Name.Length > MaxNameLength || !IsDisplaySafe(key.Name))
|
||||
errors.Add($"Clé {label} : nom absent, trop long ou invalide.");
|
||||
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.All.Contains(s, StringComparer.Ordinal)))
|
||||
errors.Add($"Clé {label} : autorisations absentes ou inconnues.");
|
||||
}
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nom affichable sans tromperie : ni caractères de contrôle, ni caractères de mise en forme invisibles
|
||||
/// (inversion du sens d'écriture…), qui pourraient maquiller un nom dans la console ou les fenêtres.
|
||||
/// </summary>
|
||||
public static bool IsDisplaySafe(string name) =>
|
||||
!name.Any(c => char.IsControl(c) || char.GetUnicodeCategory(c) is System.Globalization.UnicodeCategory.Format
|
||||
or System.Globalization.UnicodeCategory.LineSeparator
|
||||
or System.Globalization.UnicodeCategory.ParagraphSeparator);
|
||||
|
||||
/// <summary>Signe le trousseau avec la clé racine (LauncherPublisher).</summary>
|
||||
public static KeyringDocument Sign(KeyringDocument keyring, ECDsa rootKey)
|
||||
{
|
||||
var rootKeyId = AdminKeys.ComputeKeyId(rootKey.ExportSubjectPublicKeyInfo());
|
||||
var unsigned = keyring with { Signature = null, IssuedAt = ContentSigning.Truncate(keyring.IssuedAt) };
|
||||
var signature = AdminKeys.Sign(rootKey, SignedMessages.Keyring(rootKeyId, Digest(unsigned)));
|
||||
return unsigned with { Signature = new KeyringSignature { RootKeyId = rootKeyId, Value = Convert.ToBase64String(signature) } };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie la signature racine avec les clés publiques intégrées au launcher (celles de update-signing.pub).
|
||||
/// </summary>
|
||||
/// <param name="rootKeys">Identifiant et clé publique SPKI de chaque clé racine de confiance.</param>
|
||||
public static bool VerifyRoot(KeyringDocument keyring, IEnumerable<(string KeyId, byte[] PublicKey)> rootKeys, out string error)
|
||||
{
|
||||
error = "";
|
||||
if (keyring.Signature is not { } signature)
|
||||
{
|
||||
error = "trousseau non signé par la clé racine";
|
||||
return false;
|
||||
}
|
||||
var root = rootKeys.FirstOrDefault(k => string.Equals(k.KeyId, signature.RootKeyId, StringComparison.OrdinalIgnoreCase));
|
||||
if (root.PublicKey is null)
|
||||
{
|
||||
error = $"trousseau signé par une clé racine inconnue de ce launcher ({signature.RootKeyId})";
|
||||
return false;
|
||||
}
|
||||
if (AdminKeys.DecodeSignature(signature.Value) is not { } value ||
|
||||
!AdminKeys.Verify(root.PublicKey, SignedMessages.Keyring(signature.RootKeyId.ToLowerInvariant(), Digest(keyring)), value))
|
||||
{
|
||||
error = "SIGNATURE DU TROUSSEAU INVALIDE";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace GameLauncher.Contracts.Signing;
|
||||
|
||||
/// <summary>Mise en ligne d'une version : signature du manifest par l'administrateur qui publie.</summary>
|
||||
public sealed record PublicationCommitRequest
|
||||
{
|
||||
/// <summary>Signature du contenu du manifest envoyé (<see cref="ContentSigning.ManifestDigest(ManifestContent)"/>).</summary>
|
||||
public ContentSignature? Signature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature du manifest en ligne d'un jeu (avec le condensé calculé par le serveur).</summary>
|
||||
public sealed record ManifestSignatureState
|
||||
{
|
||||
public string GameId { get; init; } = "";
|
||||
|
||||
/// <summary>Version en ligne (vide : aucun manifest publié).</summary>
|
||||
public string Version { get; init; } = "";
|
||||
|
||||
/// <summary>Condensé du contenu signé de la version en ligne (vide : aucun manifest publié).</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
|
||||
/// <summary>Administrateur et appareil de la clé qui a signé, si le serveur la connaît.</summary>
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature de la version en ligne (déjà publiée, par exemple avec WinSCP) à partir du manifest.json du dossier
|
||||
/// de publication : le serveur ne l'accepte que si le contenu en ligne est identique.
|
||||
/// </summary>
|
||||
public sealed record ManifestSignRequest
|
||||
{
|
||||
/// <summary>Condensé calculé par le launcher à partir du manifest.json local.</summary>
|
||||
public string Digest { get; init; } = "";
|
||||
|
||||
public ContentSignature Signature { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Signatures du contenu, pour la page « Signature » (propriétaires).</summary>
|
||||
public sealed record SigningState
|
||||
{
|
||||
/// <summary>Trousseau publié (null : aucun).</summary>
|
||||
public KeyringDocument? Keyring { get; init; }
|
||||
|
||||
/// <summary>Faux : publication du trousseau non configurée (LauncherApi:Keyring:PublicFile, ou catalogue absent).</summary>
|
||||
public bool KeyringPublishingEnabled { get; init; }
|
||||
|
||||
/// <summary>Adresse publique du trousseau (à mettre dans « keyringUrl » de launcher.json).</summary>
|
||||
public string? KeyringPublicUrl { get; init; }
|
||||
|
||||
/// <summary>Vrai : le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require).</summary>
|
||||
public bool RequireSignatures { get; init; }
|
||||
|
||||
public List<ManifestSignatureState> Manifests { get; init; } = new();
|
||||
|
||||
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
|
||||
public CatalogSignatureState? Catalog { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CatalogSignatureState
|
||||
{
|
||||
public long Revision { get; init; }
|
||||
public string Digest { get; init; } = "";
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Catalog;
|
||||
@@ -27,14 +28,19 @@ public sealed class CatalogService
|
||||
/// <summary>Téléchargements d'images en cours (une même image n'est téléchargée qu'une fois à la fois).</summary>
|
||||
private readonly ConcurrentDictionary<string, Lazy<Task<string?>>> _imageDownloads = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl)
|
||||
: this(http.GetBytesAsync, dataDirectory, catalogUrl) { }
|
||||
private readonly ContentTrustService? _trust;
|
||||
|
||||
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl, ContentTrustService? trust = null)
|
||||
: this(http.GetBytesAsync, dataDirectory, catalogUrl, trust) { }
|
||||
|
||||
/// <param name="download">Téléchargement borné (tests : téléchargement simulé).</param>
|
||||
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl)
|
||||
/// <param name="trust">Vérification de la signature (phase 7) : en mode « require », un catalogue refusé est ignoré.</param>
|
||||
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl,
|
||||
ContentTrustService? trust = null)
|
||||
{
|
||||
_download = download;
|
||||
_catalogUrl = catalogUrl;
|
||||
_trust = trust;
|
||||
Directory = Path.Combine(dataDirectory, FolderName);
|
||||
CachePath = Path.Combine(Directory, "catalog.json");
|
||||
ImagesDirectory = Path.Combine(Directory, "images");
|
||||
@@ -44,13 +50,18 @@ public sealed class CatalogService
|
||||
public string CachePath { get; }
|
||||
public string ImagesDirectory { get; }
|
||||
|
||||
/// <summary>Catalogue du cache (null : absent ou illisible).</summary>
|
||||
/// <summary>
|
||||
/// Catalogue du cache (null : absent, illisible ou, en mode « require », non signé). Le cache contient le
|
||||
/// catalogue tel que publié (signature comprise) : il est revérifié puis contrôlé à chaque lecture.
|
||||
/// </summary>
|
||||
public CatalogDocument? LoadCached()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > CatalogRules.MaxFileBytes) return null;
|
||||
return Parse(File.ReadAllBytes(CachePath), out _);
|
||||
if (ParseRaw(File.ReadAllBytes(CachePath)) is not { } raw) return null;
|
||||
if (_trust is not null && !_trust.VerifyCachedCatalog(raw)) return null;
|
||||
return CatalogRules.Normalize(raw, _catalogUrl.Host, new List<string>());
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
|
||||
{
|
||||
@@ -68,12 +79,16 @@ public sealed class CatalogService
|
||||
try
|
||||
{
|
||||
var bytes = await _download(_catalogUrl, CatalogRules.MaxFileBytes, ct).ConfigureAwait(false);
|
||||
var catalog = Parse(bytes, out var errors);
|
||||
if (catalog is null) return null;
|
||||
if (ParseRaw(bytes) is not { } raw) return null;
|
||||
// Signature vérifiée sur le catalogue tel que publié, avant tout contrôle des valeurs.
|
||||
if (_trust is not null && !await _trust.VerifyCatalogAsync(raw, ct).ConfigureAwait(false)) return null;
|
||||
var errors = new List<string>();
|
||||
var catalog = CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
|
||||
foreach (var error in errors.Take(10)) Log.Warn($"Catalogue : valeur ignorée — {error}");
|
||||
|
||||
// Cache : le catalogue tel que publié (la signature reste vérifiable hors ligne).
|
||||
System.IO.Directory.CreateDirectory(Directory);
|
||||
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(catalog, ApiJson.Options));
|
||||
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(raw, ApiJson.Options));
|
||||
return catalog;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
@@ -88,10 +103,9 @@ public sealed class CatalogService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lecture + contrôle (valeurs invalides retirées). Null si le format est inconnu.</summary>
|
||||
private CatalogDocument? Parse(byte[] bytes, out List<string> errors)
|
||||
/// <summary>Catalogue tel que publié (null : format non pris en charge).</summary>
|
||||
private static CatalogDocument? ParseRaw(byte[] bytes)
|
||||
{
|
||||
errors = new List<string>();
|
||||
var raw = JsonSerializer.Deserialize<CatalogDocument>(Encoding.UTF8.GetString(bytes).TrimStart(''), ApiJson.Options)
|
||||
?? throw new InvalidDataException("catalogue vide");
|
||||
if (raw.Format != CatalogDocument.FormatName || raw.Version != CatalogDocument.CurrentVersion)
|
||||
@@ -99,7 +113,7 @@ public sealed class CatalogService
|
||||
Log.Warn($"Catalogue ignoré : format « {raw.Format} » version {raw.Version} non pris en charge par cette version du launcher.");
|
||||
return null;
|
||||
}
|
||||
return CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Images
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
@@ -40,9 +41,28 @@ public sealed class GameManifest
|
||||
|
||||
public List<ChangelogEntry> Changelog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Signature de l'administrateur qui a mis la version en ligne (phase 7), vérifiée avec le trousseau
|
||||
/// (<see cref="Trust.ContentTrustService"/>). Absente des manifests produits par ManifestBuilder.
|
||||
/// </summary>
|
||||
public ContentSignature? Signature { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public long TotalSize => Files.Sum(f => f.Size);
|
||||
|
||||
/// <summary>Partie couverte par la signature (tout ce qui décide de ce qui est installé et exécuté).</summary>
|
||||
public ManifestContent ToSignedContent() => new()
|
||||
{
|
||||
FormatVersion = FormatVersion,
|
||||
GameId = GameId,
|
||||
Version = Version,
|
||||
Executable = Executable,
|
||||
LaunchArguments = LaunchArguments,
|
||||
Archive = new ManifestContentArchive { Url = Archive.Url, Size = Archive.Size, Sha256 = Archive.Sha256 },
|
||||
ObjectsBaseUrl = ObjectsBaseUrl,
|
||||
Files = Files.Select(f => new ManifestContentFile { Path = f.Path, Size = f.Size, Sha256 = f.Sha256 }).ToList()
|
||||
};
|
||||
|
||||
/// <summary>URL d'où le manifest a été téléchargé (sert à résoudre les URL relatives).</summary>
|
||||
[JsonIgnore]
|
||||
public Uri? SourceUri { get; set; }
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace GameLauncher.Core.Models;
|
||||
/// </summary>
|
||||
public sealed class LauncherCatalog
|
||||
{
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl" };
|
||||
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl", "keyringUrl", "contentSigning", "keyringMinSequence" };
|
||||
|
||||
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\<Publisher>\GameLauncher).</summary>
|
||||
public const string DefaultDataFolderName = "GameLauncher";
|
||||
@@ -98,6 +98,34 @@ public sealed class LauncherCatalog
|
||||
/// <summary>Raison pour laquelle « catalogUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? CatalogUrlError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Adresse du trousseau (keyring.json : clés autorisées à signer le contenu, signé par la clé racine du
|
||||
/// launcher), ex. « https://launcher.lanisys.fr/catalog/keyring.json ». Facultatif. Comme <see cref="ApiUrl"/>,
|
||||
/// le launcher publié ne la lit que dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public string? KeyringUrl { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « keyringUrl » a été ignorée (valeur invalide), sinon null.</summary>
|
||||
public string? KeyringUrlError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// « contentSigning » : vérification des signatures du manifest et du catalogue. « warn » (défaut si
|
||||
/// keyringUrl est renseignée) : un contenu non signé ou mal signé est signalé dans le journal mais utilisé ;
|
||||
/// « require » : il est refusé (aucune installation ni mise à jour, catalogue ignoré) ; « off » : rien n'est vérifié.
|
||||
/// Comme keyringUrl, lu uniquement dans le launcher.json intégré à l'exe.
|
||||
/// </summary>
|
||||
public Trust.ContentSigningMode ContentSigning { get; private set; }
|
||||
|
||||
/// <summary>Raison pour laquelle « contentSigning » a été ignorée ou est incohérente, sinon null.</summary>
|
||||
public string? ContentSigningError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// « keyringMinSequence » : numéro minimal du trousseau accepté (0 par défaut). Un trousseau plus ancien, même
|
||||
/// signé par la clé racine, est refusé : à relever dans le launcher publié après le retrait d'une clé compromise,
|
||||
/// pour qu'une installation neuve n'accepte pas un ancien trousseau qui la contenait encore. Intégré à l'exe.
|
||||
/// </summary>
|
||||
public long KeyringMinSequence { get; private set; }
|
||||
|
||||
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
|
||||
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
|
||||
{
|
||||
@@ -290,7 +318,7 @@ public sealed class LauncherCatalog
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
|
||||
CatalogUrlError = singleCatalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
};
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
if (gamesNode is not JsonArray array || array.Count == 0)
|
||||
@@ -345,7 +373,37 @@ public sealed class LauncherCatalog
|
||||
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
|
||||
CatalogUrlError = catalogError,
|
||||
DataFolderName = ReadDataFolder(root)
|
||||
}.WithContentSigning(root);
|
||||
}
|
||||
|
||||
/// <summary>« keyringUrl », « contentSigning » et « keyringMinSequence » (phase 7).</summary>
|
||||
private LauncherCatalog WithContentSigning(JsonObject root)
|
||||
{
|
||||
KeyringUrl = ReadTrustedUrl(root, "keyringUrl", "https://launcher.exemple.fr/catalog/keyring.json", out var keyringError);
|
||||
KeyringUrlError = keyringError;
|
||||
var text = Get(root, "contentSigning") is JsonValue value && value.TryGetValue<string>(out var s) ? s.Trim() : null;
|
||||
var declared = Get(root, "contentSigning") is not null;
|
||||
(ContentSigning, ContentSigningError) = text?.ToLowerInvariant() switch
|
||||
{
|
||||
"off" => (Trust.ContentSigningMode.Off, null),
|
||||
"warn" => (Trust.ContentSigningMode.Warn, null),
|
||||
"require" => (Trust.ContentSigningMode.Require, null),
|
||||
_ when !declared => (KeyringUrl is null ? Trust.ContentSigningMode.Off : Trust.ContentSigningMode.Warn, null),
|
||||
// Valeur invalide : prudence (vérification sans blocage), signalée dans le journal.
|
||||
_ => (Trust.ContentSigningMode.Warn,
|
||||
"launcher.json : contentSigning ignorée (valeurs possibles : \"off\", \"warn\", \"require\") : vérification sans blocage (« warn »).")
|
||||
};
|
||||
if (Get(root, "keyringMinSequence") is { } minNode)
|
||||
{
|
||||
if (minNode is JsonValue minValue && minValue.TryGetValue<long>(out var min) && min >= 0)
|
||||
KeyringMinSequence = min;
|
||||
else
|
||||
ContentSigningError ??= "launcher.json : keyringMinSequence ignorée (nombre entier positif attendu).";
|
||||
}
|
||||
if (ContentSigning == Trust.ContentSigningMode.Require && KeyringUrl is null)
|
||||
ContentSigningError = "launcher.json : contentSigning « require » sans keyringUrl valide : aucun manifest ni catalogue ne pourra être " +
|
||||
"vérifié, donc aucune installation ni mise à jour ne sera possible.";
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>« dataFolder » : nom simple de dossier (« GameLauncher » par défaut).</summary>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Services;
|
||||
@@ -9,11 +10,16 @@ public sealed class ManifestClient
|
||||
{
|
||||
private readonly HttpDownloader _http;
|
||||
private readonly Uri _manifestUri;
|
||||
private readonly ContentTrustService? _trust;
|
||||
private readonly string? _gameId;
|
||||
|
||||
public ManifestClient(HttpDownloader http, Uri manifestUri)
|
||||
/// <param name="trust">Vérification des signatures (phase 7), avec l'identifiant du jeu de launcher.json.</param>
|
||||
public ManifestClient(HttpDownloader http, Uri manifestUri, ContentTrustService? trust = null, string? gameId = null)
|
||||
{
|
||||
_http = http;
|
||||
_manifestUri = manifestUri;
|
||||
_trust = trust;
|
||||
_gameId = gameId;
|
||||
}
|
||||
|
||||
public async Task<GameManifest> FetchAsync(CancellationToken ct = default)
|
||||
@@ -32,6 +38,8 @@ public sealed class ManifestClient
|
||||
|
||||
manifest.SourceUri = _manifestUri;
|
||||
manifest.Validate();
|
||||
// Signature (phase 7) : refus en mode « require » (ContentSecurityException), sinon journal seulement.
|
||||
if (_trust is not null && _gameId is not null) await _trust.VerifyManifestAsync(manifest, _gameId, ct).ConfigureAwait(false);
|
||||
// Notes de version bornées et nettoyées : elles sont affichées telles quelles dans l'interface.
|
||||
manifest.Changelog = ChangelogLimits.Normalize(manifest.Changelog);
|
||||
Log.Info($"Manifest récupéré : version {manifest.Version}, {manifest.Files.Count} fichiers, {Format.Bytes(manifest.TotalSize)}.");
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Trust;
|
||||
|
||||
/// <summary>Vérification des signatures du contenu (« contentSigning » de launcher.json).</summary>
|
||||
public enum ContentSigningMode
|
||||
{
|
||||
/// <summary>Rien n'est vérifié (comportement antérieur à la phase 7).</summary>
|
||||
Off,
|
||||
|
||||
/// <summary>Signatures vérifiées ; un problème est inscrit au journal, le contenu est utilisé quand même (mise en place).</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Un contenu non signé, mal signé ou plus ancien que le dernier vu est refusé.</summary>
|
||||
Require
|
||||
}
|
||||
|
||||
/// <summary>Contenu refusé (mode « require ») : signature absente ou invalide, trousseau indisponible, retour en arrière.</summary>
|
||||
public sealed class ContentSecurityException : Exception
|
||||
{
|
||||
public ContentSecurityException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confiance dans le contenu servi par le serveur (phase 7) :
|
||||
/// • trousseau (keyring.json) téléchargé puis vérifié avec la ou les clés RACINE intégrées à l'exe (celles des
|
||||
/// mises à jour du launcher), conservé en cache ; un trousseau plus ancien que le dernier vu est refusé ;
|
||||
/// • manifest et catalogue : signature d'une clé du trousseau autorisée pour ce contenu, sur le condensé calculé
|
||||
/// ici à partir de ce qui a été téléchargé ; un contenu signé plus tôt que le dernier accepté est refusé (un
|
||||
/// serveur compromis ne peut pas resservir une ancienne version vulnérable) ;
|
||||
/// • mode « warn » : problèmes inscrits au journal sans blocage ; « require » : contenu refusé.
|
||||
/// État conservé dans le dossier des données du launcher (trust\content-trust.json).
|
||||
/// </summary>
|
||||
public sealed class ContentTrustService
|
||||
{
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly Uri? _keyringUrl;
|
||||
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly object _lock = new();
|
||||
private Task<KeyringDocument?>? _keyringTask;
|
||||
private bool _keyringFetched;
|
||||
private DateTimeOffset _lastAttempt;
|
||||
private TrustState? _state;
|
||||
|
||||
/// <param name="rootKeys">Clés racine de confiance (update-signing.pub intégré à l'exe).</param>
|
||||
/// <param name="minKeyringSequence">
|
||||
/// Numéro minimal du trousseau (« keyringMinSequence » de launcher.json) : un trousseau plus ancien, même signé par
|
||||
/// la racine, est refusé. À relever après le retrait d'une clé compromise, pour les installations neuves.
|
||||
/// </param>
|
||||
/// <param name="time">Horloge (tests).</param>
|
||||
public ContentTrustService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri? keyringUrl,
|
||||
IReadOnlyList<(string KeyId, byte[] PublicKey)> rootKeys, ContentSigningMode mode, long minKeyringSequence = 0,
|
||||
TimeProvider? time = null)
|
||||
{
|
||||
_download = download;
|
||||
_time = time ?? TimeProvider.System;
|
||||
_keyringUrl = keyringUrl;
|
||||
_rootKeys = rootKeys;
|
||||
Mode = mode;
|
||||
MinKeyringSequence = Math.Max(0, minKeyringSequence);
|
||||
StatePath = Path.Combine(dataDirectory, "trust", "content-trust.json");
|
||||
}
|
||||
|
||||
public ContentSigningMode Mode { get; }
|
||||
public long MinKeyringSequence { get; }
|
||||
public string StatePath { get; }
|
||||
|
||||
// ------------------------------------------------------------------ Trousseau
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
|
||||
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
|
||||
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
|
||||
/// </summary>
|
||||
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
|
||||
|
||||
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
|
||||
{
|
||||
Task<KeyringDocument?> task;
|
||||
lock (_lock)
|
||||
{
|
||||
var elapsed = _time.GetUtcNow() - _lastAttempt;
|
||||
if (_keyringTask is null
|
||||
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|
||||
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
|
||||
{
|
||||
_lastAttempt = _time.GetUtcNow();
|
||||
_keyringTask = FetchKeyringAsync();
|
||||
}
|
||||
task = _keyringTask;
|
||||
}
|
||||
return await task.WaitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Dernier trousseau vérifié en cache (sans réseau).</summary>
|
||||
public KeyringDocument? CachedKeyring => LoadState().Keyring;
|
||||
|
||||
private async Task<KeyringDocument?> FetchKeyringAsync()
|
||||
{
|
||||
var cached = LoadState().Keyring;
|
||||
if (_keyringUrl is null || Mode == ContentSigningMode.Off) return cached;
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(FetchTimeout);
|
||||
var bytes = await _download(_keyringUrl, Keyring.MaxBytes, timeout.Token).ConfigureAwait(false);
|
||||
var keyring = Keyring.Parse(bytes) ?? throw new InvalidDataException("trousseau vide");
|
||||
var errors = new List<string>();
|
||||
if (!Keyring.Validate(keyring, errors)) throw new InvalidDataException(errors[0]);
|
||||
if (!Keyring.VerifyRoot(keyring, _rootKeys, out var error)) throw new InvalidDataException(error);
|
||||
if (keyring.Sequence < MinKeyringSequence)
|
||||
throw new InvalidDataException($"trousseau n° {keyring.Sequence} plus ancien que le minimum n° {MinKeyringSequence} de ce launcher");
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_keyringFetched = true;
|
||||
if (cached is not null)
|
||||
{
|
||||
if (keyring.Sequence < cached.Sequence)
|
||||
{
|
||||
Log.Error($"Trousseau n° {keyring.Sequence} refusé : plus ancien que le n° {cached.Sequence} déjà vu (retour en arrière).");
|
||||
return cached;
|
||||
}
|
||||
if (keyring.Sequence == cached.Sequence && Keyring.Digest(keyring) != Keyring.Digest(cached))
|
||||
{
|
||||
Log.Error($"Trousseau n° {keyring.Sequence} refusé : différent de celui déjà vu sous le même numéro.");
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
var state = LoadState();
|
||||
if (cached is null || keyring.Sequence > cached.Sequence)
|
||||
{
|
||||
state.Keyring = keyring;
|
||||
SaveState(state);
|
||||
Log.Info($"Trousseau n° {keyring.Sequence} vérifié ({keyring.Keys.Count} clé(s)).");
|
||||
}
|
||||
return keyring;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
|
||||
{
|
||||
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
|
||||
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
|
||||
return cached;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Réseau, délai dépassé, réponse trop grande… : jamais d'exception vers l'appelant, nouvel essai plus tard.
|
||||
Log.Warn($"Trousseau injoignable : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu est utilisé)."));
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Contenu
|
||||
|
||||
/// <summary>Vérifie le manifest d'un jeu (lève <see cref="ContentSecurityException"/> en mode « require »).</summary>
|
||||
public Task VerifyManifestAsync(GameManifest manifest, string gameId, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return Task.CompletedTask;
|
||||
return VerifyAsync(ContentKinds.Manifest, gameId, ContentSigning.ManifestDigest(manifest.ToSignedContent()), manifest.Signature,
|
||||
$"Manifest de {gameId} (version {manifest.Version})", ct);
|
||||
}
|
||||
|
||||
/// <summary>Vérifie le catalogue tel que téléchargé (avant tout contrôle des valeurs). Faux : à ignorer.</summary>
|
||||
public async Task<bool> VerifyCatalogAsync(CatalogDocument catalog, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
var what = $"Catalogue (révision {catalog.Revision})";
|
||||
try
|
||||
{
|
||||
if (ContentSigning.HasDuplicateGames(catalog))
|
||||
return Report(what, "jeux en double (identifiants ne différant que par la casse)");
|
||||
await VerifyAsync(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, what, ct)
|
||||
.ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (ContentSecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revérifie le catalogue en cache au démarrage, sans réseau, avec le dernier trousseau vérifié : en mode
|
||||
/// « require », un catalogue en cache non signé (écrit avant l'activation, ou modifié sur le disque) est ignoré.
|
||||
/// L'antériorité n'est pas contrôlée ici (le cache est ce qui a déjà été accepté).
|
||||
/// </summary>
|
||||
public bool VerifyCachedCatalog(CatalogDocument catalog)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
var what = $"Catalogue en cache (révision {catalog.Revision})";
|
||||
if (ContentSigning.HasDuplicateGames(catalog)) return Report(what, "jeux en double (identifiants ne différant que par la casse)");
|
||||
var keyring = LoadState().Keyring;
|
||||
if (keyring is null) return Report(what, "aucun trousseau vérifié en cache");
|
||||
var check = ContentSigning.Verify(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog), catalog.Signature, keyring);
|
||||
return check.Valid || Report(what, check.Reason);
|
||||
}
|
||||
|
||||
/// <summary>Problème : refus en mode « require » (faux), avertissement en mode « warn » (vrai).</summary>
|
||||
private bool Report(string what, string problem)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Require)
|
||||
{
|
||||
Log.Error($"{what} : {problem}. Contenu refusé (contentSigning « require »).");
|
||||
return false;
|
||||
}
|
||||
Log.Warn($"{what} : {problem}. Contenu utilisé quand même (contentSigning « warn »).");
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
|
||||
{
|
||||
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
|
||||
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
|
||||
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
|
||||
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
|
||||
|
||||
string problem;
|
||||
if (keyring is null)
|
||||
{
|
||||
problem = _keyringUrl is null ? "aucun trousseau configuré (keyringUrl)" : "trousseau indisponible (jamais téléchargé)";
|
||||
}
|
||||
else
|
||||
{
|
||||
var check = ContentSigning.Verify(kind, id, digest, signature, keyring);
|
||||
if (!check.Valid)
|
||||
{
|
||||
problem = check.Reason;
|
||||
}
|
||||
else
|
||||
{
|
||||
var slot = $"{kind}:{ContentSigning.SignedId(kind, id)}";
|
||||
lock (_lock)
|
||||
{
|
||||
var state = LoadState();
|
||||
if (state.LastSigned.TryGetValue(slot, out var last) && signature!.SignedAt < last)
|
||||
{
|
||||
problem = $"signé le {signature.SignedAt:yyyy-MM-dd HH:mm} UTC, plus ancien que le contenu déjà vu " +
|
||||
$"(signé le {last:yyyy-MM-dd HH:mm} UTC) : retour en arrière refusé";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Date enregistrée plafonnée : une signature datée loin dans le futur (horloge déréglée) ne doit
|
||||
// pas faire refuser toutes les publications suivantes.
|
||||
var ceiling = ContentSigning.Truncate(_time.GetUtcNow() + MaxFutureSignedAt);
|
||||
var recorded = signature!.SignedAt > ceiling ? ceiling : signature.SignedAt;
|
||||
if (recorded > last)
|
||||
{
|
||||
state.LastSigned[slot] = recorded;
|
||||
SaveState(state);
|
||||
}
|
||||
Log.Info($"{what} : {check.Reason}.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
private sealed class TrustState
|
||||
{
|
||||
public KeyringDocument? Keyring { get; set; }
|
||||
public Dictionary<string, DateTimeOffset> LastSigned { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>État en cache (le trousseau est revérifié à la lecture : un fichier modifié à la main est ignoré).</summary>
|
||||
private TrustState LoadState()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_state is not null) return _state;
|
||||
var state = new TrustState();
|
||||
try
|
||||
{
|
||||
if (File.Exists(StatePath) && new FileInfo(StatePath).Length <= Keyring.MaxBytes * 2)
|
||||
{
|
||||
var read = JsonSerializer.Deserialize<TrustState>(File.ReadAllText(StatePath), ApiJson.Options);
|
||||
if (read is not null)
|
||||
{
|
||||
state.LastSigned = new Dictionary<string, DateTimeOffset>(read.LastSigned ?? new(), StringComparer.Ordinal);
|
||||
var errors = new List<string>();
|
||||
if (read.Keyring is { } keyring && Keyring.Validate(keyring, errors) && Keyring.VerifyRoot(keyring, _rootKeys, out _)
|
||||
&& keyring.Sequence >= MinKeyringSequence)
|
||||
state.Keyring = keyring;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
Log.Warn($"État de vérification des signatures illisible ({ex.Message}) : recréé.");
|
||||
}
|
||||
return _state = state;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveState(TrustState state)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!);
|
||||
FileUtil.WriteAllTextAtomic(StatePath, JsonSerializer.Serialize(state, new JsonSerializerOptions(ApiJson.Options) { WriteIndented = true }));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"État de vérification des signatures non enregistré : {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,18 +172,27 @@ public abstract class AccountsPanelBase : AdminPanelViewModel
|
||||
public sealed class AdminKeyItem : ObservableObject
|
||||
{
|
||||
private string _fingerprintInput = "";
|
||||
private readonly string? _fingerprintWarning;
|
||||
|
||||
internal AdminKeyItem(AccountsPanelBase page, Guid adminId, string adminName, AdminKeySummary key, bool isCurrentSession,
|
||||
bool canApprove, string? approveHint, bool canRevoke, string pendingText)
|
||||
{
|
||||
FingerprintText = key.Fingerprint ?? pendingText;
|
||||
// Cet appareil : empreinte calculée sur le PC (celle à dicter lors de la vérification du trousseau), pas celle
|
||||
// annoncée par le serveur ; une différence est signalée.
|
||||
var local = isCurrentSession ? page.Context.Session.LocalFingerprint : null;
|
||||
if (local is not null && key.Fingerprint is not null &&
|
||||
!string.Equals(AdminKeys.NormalizeFingerprint(local), AdminKeys.NormalizeFingerprint(key.Fingerprint), StringComparison.Ordinal))
|
||||
_fingerprintWarning = $" · ATTENTION : le serveur annonce une autre empreinte ({key.Fingerprint})";
|
||||
else if (local is not null)
|
||||
_fingerprintWarning = " · empreinte calculée sur ce PC";
|
||||
FingerprintText = local ?? key.Fingerprint ?? pendingText;
|
||||
AdminId = adminId;
|
||||
AdminName = adminName;
|
||||
KeyId = key.KeyId;
|
||||
Label = AccountTexts.KeyLabel(key.Label);
|
||||
Status = key.Status;
|
||||
IsCurrentSession = isCurrentSession;
|
||||
Fingerprint = key.Fingerprint;
|
||||
Fingerprint = local ?? key.Fingerprint;
|
||||
AddedText = $"Ajouté le {AccountTexts.Date(key.AddedAt)}";
|
||||
LastUsedText = key.LastUsedAt is { } used ? $"Dernière connexion le {AccountTexts.Date(used)}" : "Jamais utilisé";
|
||||
CanApprove = canApprove && IsPending;
|
||||
@@ -209,7 +220,7 @@ public sealed class AdminKeyItem : ObservableObject
|
||||
public string FingerprintText { get; }
|
||||
public string AddedText { get; }
|
||||
public string LastUsedText { get; }
|
||||
public string DetailText => $"{AddedText} · {LastUsedText} · clé {KeyId}";
|
||||
public string DetailText => $"{AddedText} · {LastUsedText} · clé {KeyId}{_fingerprintWarning}";
|
||||
|
||||
public bool CanApprove { get; }
|
||||
|
||||
|
||||
@@ -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).";
|
||||
|
||||
@@ -4,6 +4,7 @@ using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
@@ -35,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));
|
||||
}
|
||||
@@ -186,7 +187,10 @@ public sealed class GamesPanelViewModel : AdminPanelViewModel
|
||||
|
||||
try
|
||||
{
|
||||
var published = await Context.Session.PublishCatalogAsync(draft, state.Catalog.Revision);
|
||||
// Signature (phase 7) du catalogue tel qu'il sera publié (contrôlé avec les mêmes règles que le serveur).
|
||||
var signature = await Context.Session.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId,
|
||||
ContentSigning.CatalogDigest(normalized));
|
||||
var published = await Context.Session.PublishCatalogAsync(normalized with { Signature = signature }, state.Catalog.Revision);
|
||||
Apply(published);
|
||||
StatusMessage = $"Catalogue publié (révision {published.Catalog.Revision}). Les launchers l'appliquent à leur prochain démarrage.";
|
||||
Log.Info($"Administration : catalogue publié (révision {published.Catalog.Revision}).");
|
||||
|
||||
@@ -35,8 +35,8 @@ public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
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);
|
||||
SaveCommand = new AsyncRelayCommand(ScrollingToTop(SaveAsync), () => !IsBusy && HasChanges && !HasConflict && _state is not null);
|
||||
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy && _selectedGame is not null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Jeu
|
||||
|
||||
@@ -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.FirstOrDefault();
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
@@ -23,6 +24,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
private LocalRelease? _release;
|
||||
private string? _manifestPath, _publishedVersion, _statusMessage, _progressText, _speedText;
|
||||
private string _publishedText = "";
|
||||
private ManifestSignatureState? _online;
|
||||
private IReadOnlyList<string> _warnings = Array.Empty<string>();
|
||||
private bool _isPublishing, _committing;
|
||||
private double _progressValue;
|
||||
@@ -36,9 +38,10 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
?? 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);
|
||||
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync), () => CanEdit && _release is not null);
|
||||
CancelCommand = new RelayCommand(() => _cancellation?.Cancel(), () => _isPublishing && !_committing);
|
||||
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
|
||||
@@ -167,6 +170,73 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
public RelayCommand CancelCommand { get; }
|
||||
public AsyncRelayCommand AbandonCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ Signature de la version en ligne (phase 7)
|
||||
|
||||
/// <summary>« Version en ligne signée par … » / « non signée ».</summary>
|
||||
public string OnlineSignatureText => _online is not { Version.Length: > 0 } online ? ""
|
||||
: online.Signature is { } signature
|
||||
? $"Version en ligne {online.Version} signée par {online.SignerName ?? $"la clé {signature.KeyId}"} le {Accounts.AccountTexts.Date(signature.SignedAt)}."
|
||||
: $"Version en ligne {online.Version} non signée : les launchers en mode « require » la refusent.";
|
||||
|
||||
public bool HasOnlineSignatureText => OnlineSignatureText.Length > 0;
|
||||
public bool IsOnlineSigned => _online?.Signature is not null;
|
||||
|
||||
/// <summary>Le dossier choisi a exactement le contenu de la version en ligne : elle peut être signée avec lui.</summary>
|
||||
public bool CanSignOnline => !_isPublishing && _release is { } release && _online is { Digest.Length: > 0 } online &&
|
||||
string.Equals(online.Digest, release.ContentDigest, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Même numéro de version que celle en ligne, mais contenu différent : impossible de la signer avec ce dossier.</summary>
|
||||
public string? OnlineMismatchText => _release is { } release && _online is { Digest.Length: > 0 } online &&
|
||||
release.Summary.Version == online.Version &&
|
||||
!string.Equals(online.Digest, release.ContentDigest, StringComparison.OrdinalIgnoreCase)
|
||||
? "La version en ligne porte ce numéro mais son contenu diffère de ce dossier : elle ne peut pas être signée avec lui."
|
||||
: null;
|
||||
|
||||
public AsyncRelayCommand SignOnlineCommand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Signe la version déjà en ligne (publiée avant la signature, ou avec WinSCP) à partir du dossier choisi : le
|
||||
/// serveur n'accepte que si les fichiers en ligne sont exactement ceux de ce manifest.
|
||||
/// </summary>
|
||||
private async Task SignOnlineAsync()
|
||||
{
|
||||
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} ?",
|
||||
"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;
|
||||
StatusMessage = null;
|
||||
if (await RunAsync(async () =>
|
||||
{
|
||||
var signature = await Context.Session.SignContentAsync(ContentKinds.Manifest, gameId, release.ContentDigest);
|
||||
_online = await Context.Session.SignOnlineManifestAsync(gameId,
|
||||
new ManifestSignRequest { Digest = release.ContentDigest, Signature = signature });
|
||||
}))
|
||||
StatusMessage = $"Version en ligne {online.Version} signée.";
|
||||
NotifySignature();
|
||||
}
|
||||
|
||||
private async Task RefreshSignatureAsync(string gameId)
|
||||
{
|
||||
try
|
||||
{
|
||||
_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
|
||||
}
|
||||
NotifySignature();
|
||||
}
|
||||
|
||||
private void NotifySignature()
|
||||
{
|
||||
foreach (var name in new[] { nameof(OnlineSignatureText), nameof(HasOnlineSignatureText), nameof(IsOnlineSigned),
|
||||
nameof(CanSignOnline), nameof(OnlineMismatchText) })
|
||||
OnPropertyChanged(name);
|
||||
SignOnlineCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanEdit));
|
||||
@@ -234,6 +304,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
"). Les launchers la proposeront à leur prochaine recherche de mise à jour.";
|
||||
UpdateWarnings();
|
||||
Log.Info($"Administration : version {result.Version} de {gameId} publiée.");
|
||||
await RefreshSignatureAsync(gameId);
|
||||
}
|
||||
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested)
|
||||
{
|
||||
@@ -284,6 +355,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
var state = await Context.Session.GetChangelogAsync(gameId);
|
||||
_publishedVersion = state.ManifestVersion;
|
||||
PublishedText = $"Version en ligne : {Display(state.ManifestVersion)}";
|
||||
await RefreshSignatureAsync(gameId);
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 404)
|
||||
{
|
||||
@@ -310,6 +382,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
OnPropertyChanged(name);
|
||||
UpdateWarnings();
|
||||
RefreshCommands();
|
||||
NotifySignature();
|
||||
}
|
||||
|
||||
private void UpdateWarnings()
|
||||
@@ -363,6 +436,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
CancelCommand.RaiseCanExecuteChanged();
|
||||
AbandonCommand.RaiseCanExecuteChanged();
|
||||
SignOnlineCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
|
||||
|
||||
@@ -3,12 +3,16 @@ using System.Security.Cryptography;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Publishing;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Publishing;
|
||||
|
||||
/// <summary>Version prête à publier : dossier produit par ManifestBuilder (le .bat), contrôlé.</summary>
|
||||
public sealed record LocalRelease(string Folder, string ManifestPath, byte[] ManifestBytes, string ManifestSha256, ManifestSummary Summary)
|
||||
{
|
||||
/// <summary>Condensé du contenu signé (phase 7) : fichiers, archive, exécutable… (notes exclues).</summary>
|
||||
public string ContentDigest { get; } = ContentSigning.ManifestDigest(ManifestBytes);
|
||||
|
||||
public string ArchivePath => Path.Combine(Folder, Summary.ArchiveName);
|
||||
|
||||
public string ObjectFile(string sha256) =>
|
||||
@@ -34,6 +38,9 @@ public static class ReleaseUploader
|
||||
{
|
||||
private const int MaxAttempts = 8;
|
||||
|
||||
/// <summary>Signature réutilisée pour un nouvel essai de mise en ligne pendant ce délai au plus (serveur : ± 10 min).</summary>
|
||||
private static readonly TimeSpan SignatureReuseLimit = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Dernière étape (mise en ligne sur le serveur) : elle ne s'interrompt pas.</summary>
|
||||
public const string CommitStep = "Mise en ligne";
|
||||
|
||||
@@ -85,6 +92,8 @@ public static class ReleaseUploader
|
||||
started?.Report(state);
|
||||
|
||||
var committing = false;
|
||||
ContentSignature? signature = null;
|
||||
var signedLocally = DateTime.MinValue;
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
var before = run.Done;
|
||||
@@ -103,7 +112,15 @@ public static class ReleaseUploader
|
||||
}
|
||||
// Mise en ligne : ni interrompue (elle se termine sur le serveur de toute façon), ni perdue
|
||||
// (réponse perdue : le serveur renvoie le même résultat au nouvel essai).
|
||||
return await session.CommitPublicationAsync(state.PublicationId, CancellationToken.None).ConfigureAwait(false);
|
||||
// Signature du contenu (phase 7) juste avant la mise en ligne : le serveur la contrôle (clé de cette
|
||||
// session, date proche de son heure), puis l'intègre au manifest publié.
|
||||
// Nouvel essai après plusieurs minutes : signature refaite (le serveur refuse une date trop éloignée).
|
||||
if (signature is null || DateTime.UtcNow - signedLocally > SignatureReuseLimit)
|
||||
{
|
||||
signature = await session.SignContentAsync(ContentKinds.Manifest, gameId, release.ContentDigest).ConfigureAwait(false);
|
||||
signedLocally = DateTime.UtcNow;
|
||||
}
|
||||
return await session.CommitPublicationAsync(state.PublicationId, signature, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (ApiException ex) when (IsTransient(ex) && attempt < MaxAttempts)
|
||||
{
|
||||
|
||||
@@ -12,14 +12,21 @@ public sealed class AdminLoginService
|
||||
private readonly LauncherApiClient _client;
|
||||
private readonly string _clientName;
|
||||
private readonly IAdminTpm? _tpm;
|
||||
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
|
||||
|
||||
/// <param name="clientName">Description du poste pour le journal d'audit, ex. « GameLauncher 1.4.0 (PC-BUREAU) ».</param>
|
||||
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
|
||||
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null)
|
||||
/// <param name="rootKeys">
|
||||
/// Clés racine du launcher (update-signing.pub) : une clé d'administrateur identique est refusée, quel que soit
|
||||
/// le nom du fichier (la clé racine reste hors ligne, réservée à tools\sign-keyring.bat et aux mises à jour).
|
||||
/// </param>
|
||||
public AdminLoginService(LauncherApiClient client, string clientName, IAdminTpm? tpm = null,
|
||||
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null)
|
||||
{
|
||||
_client = client;
|
||||
_clientName = clientName;
|
||||
_tpm = tpm;
|
||||
_rootKeys = rootKeys ?? Array.Empty<(string, byte[])>();
|
||||
}
|
||||
|
||||
/// <param name="keyPath">Fichier .key (clé chiffrée) ou .tpmkey (référence d'une clé TPM).</param>
|
||||
@@ -59,6 +66,14 @@ public sealed class AdminLoginService
|
||||
key = opened;
|
||||
}
|
||||
|
||||
if (_rootKeys.Any(r => string.Equals(r.KeyId, signer.KeyId, StringComparison.Ordinal) || r.PublicKey.AsSpan().SequenceEqual(signer.PublicKey)))
|
||||
{
|
||||
key.Dispose();
|
||||
throw new InvalidOperationException(
|
||||
"Cette clé est la clé racine du launcher (signature des mises à jour et du trousseau) : elle ne doit jamais servir " +
|
||||
"à se connecter. Utilisez votre clé d'administrateur.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var session = await _client.LoginAsync(signer, _clientName, ct).ConfigureAwait(false);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Signing;
|
||||
|
||||
/// <summary>Module « Signature » : page du trousseau et des signatures du contenu (propriétaires).</summary>
|
||||
public sealed class SigningAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "signing";
|
||||
|
||||
public string DisplayName => "Signature";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
SigningPanelViewModel.PanelId,
|
||||
"Signature",
|
||||
"Clés autorisées à signer ce que reçoivent les joueurs (trousseau, signé hors ligne par la clé racine) et état des " +
|
||||
"signatures des versions en ligne et du catalogue.",
|
||||
"ShieldKey",
|
||||
AdminPermissions.AdminsManage,
|
||||
Order: 55,
|
||||
context => new SigningPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Signature » (propriétaires) : trousseau publié (vérifié avec la clé racine intégrée à l'exe), trousseau
|
||||
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne.
|
||||
/// La clé racine n'est JAMAIS ouverte par le launcher : la demande est exportée, signée hors ligne avec
|
||||
/// tools\sign-keyring.bat, puis le trousseau signé est publié ici.
|
||||
/// </summary>
|
||||
public sealed class SigningPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "signing";
|
||||
|
||||
private SigningState? _state;
|
||||
private List<KeyringKey> _proposed = new();
|
||||
private IReadOnlyList<KeyringKeyItem> _keys = Array.Empty<KeyringKeyItem>();
|
||||
private IReadOnlyList<SignedContentItem> _contents = Array.Empty<SignedContentItem>();
|
||||
private string? _statusMessage;
|
||||
|
||||
public SigningPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
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é
|
||||
|
||||
public bool HasKeyring => _state?.Keyring is not null;
|
||||
public bool PublishingEnabled => _state?.KeyringPublishingEnabled == true;
|
||||
public bool ShowPublishingDisabled => _state is { KeyringPublishingEnabled: false };
|
||||
public string? KeyringUrl => _state?.KeyringPublicUrl;
|
||||
|
||||
public string KeyringText => _state?.Keyring is { } keyring
|
||||
? $"Trousseau n° {keyring.Sequence} du {AccountTexts.Date(keyring.IssuedAt)} · {keyring.Keys.Count} clé(s)"
|
||||
: "Aucun trousseau publié : les launchers en mode « require » refusent tout contenu.";
|
||||
|
||||
/// <summary>Signature racine vérifiée avec les clés intégrées à CE launcher.</summary>
|
||||
public string RootText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_state?.Keyring is not { } keyring) return "";
|
||||
if (Context.RootKeys.Count == 0) return "Aucune clé racine intégrée à ce launcher (Security\\update-signing.pub) : vérification impossible.";
|
||||
return Keyring.VerifyRoot(keyring, Context.RootKeys, out var error)
|
||||
? $"Signé par la clé racine {keyring.Signature!.RootKeyId} (vérifié par ce launcher)."
|
||||
: $"Refusé par ce launcher : {error}.";
|
||||
}
|
||||
}
|
||||
|
||||
public bool RootValid => _state?.Keyring is { } keyring && Keyring.VerifyRoot(keyring, Context.RootKeys, out _);
|
||||
|
||||
public string RequireText => _state?.RequireSignatures == true
|
||||
? "Le serveur refuse les publications non signées (LauncherApi:ContentSigning:Require)."
|
||||
: "Le serveur accepte encore les publications non signées (LauncherApi:ContentSigning:Require désactivé).";
|
||||
|
||||
// ------------------------------------------------------------------ Trousseau proposé
|
||||
|
||||
/// <summary>Clés proposées (appareils actifs autorisés à publier) et différences avec le trousseau publié.</summary>
|
||||
public IReadOnlyList<KeyringKeyItem> Keys
|
||||
{
|
||||
get => _keys;
|
||||
private set => SetProperty(ref _keys, value);
|
||||
}
|
||||
|
||||
public bool HasChanges => _keys.Any(k => k.Change != KeyChange.None);
|
||||
|
||||
/// <summary>Nouveau trousseau à signer : clés modifiées, ou trousseau publié refusé par ce launcher (à remplacer).</summary>
|
||||
public bool NeedsNewKeyring => _proposed.Count > 0 && (HasChanges || (HasKeyring && !RootValid));
|
||||
|
||||
public string ChangesText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_state is null) return "";
|
||||
var added = _keys.Count(k => k.Change == KeyChange.Added);
|
||||
var removed = _keys.Count(k => k.Change == KeyChange.Removed);
|
||||
var changed = _keys.Count(k => k.Change == KeyChange.ScopesChanged);
|
||||
if (added + removed + changed == 0)
|
||||
return HasKeyring && !RootValid
|
||||
? "Les clés sont à jour, mais le trousseau publié est refusé par ce launcher : exportez une demande pour le remplacer."
|
||||
: "Le trousseau publié est à jour.";
|
||||
var parts = new List<string>();
|
||||
if (added > 0) parts.Add($"{added} clé(s) à ajouter");
|
||||
if (removed > 0) parts.Add($"{removed} à retirer");
|
||||
if (changed > 0) parts.Add($"{changed} autorisation(s) à modifier");
|
||||
return $"À mettre à jour : {string.Join(", ", parts)}.";
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Contenu en ligne
|
||||
|
||||
public IReadOnlyList<SignedContentItem> Contents
|
||||
{
|
||||
get => _contents;
|
||||
private set => SetProperty(ref _contents, value);
|
||||
}
|
||||
|
||||
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 ExportRequestCommand { get; }
|
||||
public AsyncRelayCommand PublishSignedCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
ExportRequestCommand.RaiseCanExecuteChanged();
|
||||
PublishSignedCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
var state = await Context.Session.GetSigningStateAsync();
|
||||
var admins = await Context.Session.ListAdminsAsync();
|
||||
_state = state;
|
||||
_proposed = Propose(admins);
|
||||
Keys = Compare(state.Keyring, _proposed, admins);
|
||||
Contents = BuildContents(state);
|
||||
foreach (var name in new[] { nameof(HasKeyring), nameof(PublishingEnabled), nameof(ShowPublishingDisabled), nameof(KeyringUrl),
|
||||
nameof(KeyringText), nameof(RootText), nameof(RootValid), nameof(RequireText), nameof(HasChanges), nameof(NeedsNewKeyring), nameof(ChangesText) })
|
||||
OnPropertyChanged(name);
|
||||
ExportRequestCommand.RaiseCanExecuteChanged();
|
||||
PublishSignedCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>Appareils actifs des comptes actifs dont le rôle permet de publier une version ou le catalogue.</summary>
|
||||
internal static List<KeyringKey> Propose(IEnumerable<AdminSummary> admins) =>
|
||||
admins.Where(a => a.Status == AdminStatus.Active)
|
||||
.SelectMany(a => a.Keys
|
||||
.Where(k => k.Status == AdminStatus.Active && k.PublicKey is not null)
|
||||
.Select(k => new KeyringKey
|
||||
{
|
||||
KeyId = k.KeyId,
|
||||
PublicKey = k.PublicKey!,
|
||||
Name = Truncate($"{a.DisplayName} — {AccountTexts.KeyLabel(k.Label)}"),
|
||||
Scopes = SigningScopes.FromPermissions(AdminRoles.PermissionsOf(a.Role)).ToList()
|
||||
}))
|
||||
.Where(k => k.Scopes.Count > 0)
|
||||
.OrderBy(k => k.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Nom public du trousseau : caractères invisibles ou de mise en forme retirés (refusés par les launchers), longueur bornée.</summary>
|
||||
internal static string Truncate(string name)
|
||||
{
|
||||
var clean = new string(name.Where(c => Keyring.IsDisplaySafe(c.ToString())).ToArray()).Trim();
|
||||
if (clean.Length > Keyring.MaxNameLength) clean = clean[..Keyring.MaxNameLength].TrimEnd();
|
||||
return clean.Length == 0 ? "Appareil" : clean;
|
||||
}
|
||||
|
||||
private static List<KeyringKeyItem> Compare(KeyringDocument? published, List<KeyringKey> proposed, List<AdminSummary> admins)
|
||||
{
|
||||
var current = published?.Keys.ToDictionary(k => k.KeyId, StringComparer.Ordinal) ?? new Dictionary<string, KeyringKey>();
|
||||
var items = proposed.Select(k =>
|
||||
{
|
||||
if (!current.TryGetValue(k.KeyId, out var old)) return new KeyringKeyItem(k, KeyChange.Added);
|
||||
return SameScopes(old, k) ? new KeyringKeyItem(k, KeyChange.None) : new KeyringKeyItem(k, KeyChange.ScopesChanged);
|
||||
}).ToList();
|
||||
foreach (var old in current.Values.Where(k => proposed.All(p => p.KeyId != k.KeyId)))
|
||||
items.Add(new KeyringKeyItem(old, KeyChange.Removed));
|
||||
return items;
|
||||
}
|
||||
|
||||
private static bool SameScopes(KeyringKey a, KeyringKey b) =>
|
||||
a.Scopes.OrderBy(s => s, StringComparer.Ordinal).SequenceEqual(b.Scopes.OrderBy(s => s, StringComparer.Ordinal));
|
||||
|
||||
private List<SignedContentItem> BuildContents(SigningState state)
|
||||
{
|
||||
var proposed = new KeyringDocument { Keys = _proposed };
|
||||
var items = state.Manifests.Select(m => new SignedContentItem(
|
||||
$"Version en ligne de {m.GameId}" + (string.IsNullOrEmpty(m.Version) ? "" : $" ({m.Version})"),
|
||||
ContentKinds.Manifest, m.GameId, m.Digest, m.Signature, m.SignerName, state.Keyring, proposed,
|
||||
"Re-signez-la depuis la page « Publier une version » (dossier de cette version, « Signer la version en ligne »).")).ToList();
|
||||
if (state.Catalog is { } catalog)
|
||||
items.Add(new SignedContentItem($"Catalogue (révision {catalog.Revision})", ContentKinds.Catalog, ContentKinds.CatalogId,
|
||||
catalog.Digest, catalog.Signature, catalog.SignerName, state.Keyring, proposed,
|
||||
"Republiez-le depuis la page « Jeux » (il est signé à chaque publication)."));
|
||||
return items;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Actions
|
||||
|
||||
private async Task ExportRequestAsync()
|
||||
{
|
||||
if (_state is not { } state) return;
|
||||
var unsignedContents = Contents.Where(c => c.WillBreak).Select(c => c.Title).ToList();
|
||||
if (unsignedContents.Count > 0 &&
|
||||
!Context.Dialogs.Confirm("Contenu à signer d'abord",
|
||||
"Avec ce trousseau, les launchers en mode « require » refuseraient :\n• " + string.Join("\n• ", unsignedContents) +
|
||||
"\n\nSignez-les d'abord avec une clé qui restera dans le trousseau, ou exportez quand même (en mode « warn », " +
|
||||
"ils seront seulement signalés).", "Exporter quand même"))
|
||||
return;
|
||||
|
||||
var request = new KeyringDocument
|
||||
{
|
||||
Sequence = (state.Keyring?.Sequence ?? 0) + 1,
|
||||
IssuedAt = ContentSigning.Truncate(DateTimeOffset.UtcNow + Context.Session.ClockOffset),
|
||||
Keys = _proposed
|
||||
};
|
||||
var path = Context.Dialogs.PickSaveFile("Enregistrer la demande de trousseau", "Trousseau (*.json)|*.json",
|
||||
$"keyring-request-{request.Sequence}.json", null);
|
||||
if (path is null) return;
|
||||
if (await RunAsync(() => File.WriteAllTextAsync(path, Keyring.Serialize(request))))
|
||||
StatusMessage = $"Demande n° {request.Sequence} enregistrée ({request.Keys.Count} clé(s)). Signez-la avec tools\\sign-keyring.bat " +
|
||||
"(clé racine, hors ligne), puis « Publier le trousseau signé… ».";
|
||||
}
|
||||
|
||||
private async Task PublishSignedAsync()
|
||||
{
|
||||
if (_state is not { } state) return;
|
||||
StatusMessage = null;
|
||||
var path = Context.Dialogs.PickOpenFile("Trousseau signé (produit par tools\\sign-keyring.bat)", "Trousseau (*.json)|*.json", null);
|
||||
if (path is null) return;
|
||||
|
||||
KeyringDocument? keyring;
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (info.Length > Keyring.MaxBytes) throw new InvalidDataException("fichier trop volumineux");
|
||||
keyring = Keyring.Parse(await File.ReadAllBytesAsync(path)) ?? throw new InvalidDataException("fichier vide");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
|
||||
{
|
||||
ErrorMessage = $"Trousseau illisible : {ex.Message}";
|
||||
return;
|
||||
}
|
||||
|
||||
// Mêmes contrôles que les launchers : un trousseau qu'ils refuseraient n'est pas publié.
|
||||
var errors = new List<string>();
|
||||
if (!Keyring.Validate(keyring, errors))
|
||||
{
|
||||
ErrorMessage = "Trousseau invalide : " + string.Join(" ", errors.Take(4));
|
||||
return;
|
||||
}
|
||||
if (Context.RootKeys.Count == 0)
|
||||
{
|
||||
ErrorMessage = "Aucune clé racine intégrée à ce launcher (Security\\update-signing.pub) : impossible de vérifier le trousseau.";
|
||||
return;
|
||||
}
|
||||
if (!Keyring.VerifyRoot(keyring, Context.RootKeys, out var rootError))
|
||||
{
|
||||
ErrorMessage = $"Trousseau refusé : {rootError}. Signez la demande avec tools\\sign-keyring.bat et la clé racine du launcher.";
|
||||
return;
|
||||
}
|
||||
if (state.Keyring is { } current && keyring.Sequence <= current.Sequence)
|
||||
{
|
||||
ErrorMessage = $"Trousseau n° {keyring.Sequence} : le n° {current.Sequence} est déjà publié. Exportez une nouvelle demande.";
|
||||
return;
|
||||
}
|
||||
|
||||
var names = keyring.Keys.Select(k => $"{k.Name} ({string.Join(", ", k.Scopes.Select(SigningScopes.DisplayName))})");
|
||||
if (!Context.Dialogs.Confirm($"Publier le trousseau n° {keyring.Sequence} ?",
|
||||
"Les launchers ne feront plus confiance qu'à ces clés :\n• " + string.Join("\n• ", names), "Publier"))
|
||||
return;
|
||||
if (await RunAsync(async () =>
|
||||
{
|
||||
await Context.Session.PublishKeyringAsync(keyring);
|
||||
await ReloadAsync();
|
||||
}))
|
||||
StatusMessage = $"Trousseau n° {keyring.Sequence} publié. Les launchers l'utilisent dès leur prochain démarrage.";
|
||||
}
|
||||
}
|
||||
|
||||
public enum KeyChange
|
||||
{
|
||||
None,
|
||||
Added,
|
||||
Removed,
|
||||
ScopesChanged
|
||||
}
|
||||
|
||||
/// <summary>Clé du trousseau proposé (ou retirée du trousseau publié).</summary>
|
||||
public sealed class KeyringKeyItem
|
||||
{
|
||||
internal KeyringKeyItem(KeyringKey key, KeyChange change)
|
||||
{
|
||||
Key = key;
|
||||
Change = change;
|
||||
ScopesText = string.Join(", ", key.Scopes.Select(SigningScopes.DisplayName));
|
||||
ChangeText = change switch
|
||||
{
|
||||
KeyChange.Added => "À ajouter",
|
||||
KeyChange.Removed => "À retirer",
|
||||
KeyChange.ScopesChanged => "Autorisations modifiées",
|
||||
_ => "Dans le trousseau"
|
||||
};
|
||||
}
|
||||
|
||||
public KeyringKey Key { get; }
|
||||
public KeyChange Change { get; }
|
||||
public string Name => Key.Name;
|
||||
public string KeyId => Key.KeyId;
|
||||
public string ScopesText { get; }
|
||||
public string ChangeText { get; }
|
||||
public bool IsAdded => Change == KeyChange.Added;
|
||||
public bool IsRemoved => Change == KeyChange.Removed;
|
||||
public bool IsChanged => Change == KeyChange.ScopesChanged;
|
||||
}
|
||||
|
||||
/// <summary>Contenu en ligne et sa signature.</summary>
|
||||
public sealed class SignedContentItem
|
||||
{
|
||||
internal SignedContentItem(string title, string kind, string id, string digest, ContentSignature? signature, string? signerName,
|
||||
KeyringDocument? published, KeyringDocument proposed, string howToSign)
|
||||
{
|
||||
Title = title;
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
Text = "Rien n'est publié.";
|
||||
IsValid = true;
|
||||
return;
|
||||
}
|
||||
var signer = signature is null ? null : $"{signerName ?? $"clé {signature.KeyId}"}, le {AccountTexts.Date(signature.SignedAt)}";
|
||||
var now = published is null ? ContentCheck.Fail("aucun trousseau publié") : ContentSigning.Verify(kind, id, digest, signature, published);
|
||||
var next = ContentSigning.Verify(kind, id, digest, signature, proposed);
|
||||
IsValid = now.Valid;
|
||||
WillBreak = !next.Valid;
|
||||
Text = signature is null
|
||||
? $"Non signé. {howToSign}"
|
||||
: now.Valid
|
||||
? $"Signé par {signer} : valide pour les launchers."
|
||||
: $"Signé par {signer}, mais refusé par les launchers : {now.Reason}. {howToSign}";
|
||||
if (signature is not null && now.Valid && !next.Valid)
|
||||
Text += $" Attention : sa clé ne sera plus dans le trousseau proposé. {howToSign}";
|
||||
}
|
||||
|
||||
public string Title { get; }
|
||||
public string Text { get; }
|
||||
|
||||
/// <summary>Accepté par les launchers avec le trousseau publié.</summary>
|
||||
public bool IsValid { get; }
|
||||
|
||||
/// <summary>Serait refusé avec le trousseau proposé.</summary>
|
||||
public bool WillBreak { get; }
|
||||
}
|
||||
@@ -35,6 +35,12 @@ public sealed class AdminEnvironment
|
||||
|
||||
public TimeProvider Time { get; init; } = TimeProvider.System;
|
||||
|
||||
/// <summary>
|
||||
/// Clés racine intégrées à l'exe (update-signing.pub) : vérification d'un trousseau signé avant sa publication
|
||||
/// (page « Signature »). Vide : aucune clé intégrée.
|
||||
/// </summary>
|
||||
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; init; } = Array.Empty<(string, byte[])>();
|
||||
|
||||
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
|
||||
public IAdminTpm? Tpm { get; init; }
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
|
||||
public IReadOnlyList<string> Themes => _shell.Environment.Themes;
|
||||
public IModuleDialogs Dialogs => _shell.Environment.Dialogs;
|
||||
public string LauncherVersion => _shell.Environment.LauncherVersion;
|
||||
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
|
||||
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ public sealed class LoginViewModel : ObservableObject, IDisposable
|
||||
try
|
||||
{
|
||||
var environment = _shell.Environment;
|
||||
connection = await new AdminLoginService(environment.Client, environment.ClientName, environment.Tpm).LoginAsync(key.Path, passphrase);
|
||||
connection = await new AdminLoginService(environment.Client, environment.ClientName, environment.Tpm, environment.RootKeys).LoginAsync(key.Path, passphrase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,6 +27,9 @@ public interface IAdminContext
|
||||
|
||||
string LauncherVersion { get; }
|
||||
|
||||
/// <summary>Clés racine intégrées à l'exe (vérification du trousseau avant sa publication).</summary>
|
||||
IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; }
|
||||
|
||||
/// <summary>Pages visibles pour cet administrateur.</summary>
|
||||
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ using GameLauncher.Core.Catalog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
@@ -17,7 +18,9 @@ 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;
|
||||
using GameLauncher.Services;
|
||||
using GameLauncher.ViewModels;
|
||||
@@ -54,6 +57,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private LauncherUpdateService? _updates;
|
||||
private AdminWindowService? _admin;
|
||||
private CatalogService? _catalogService;
|
||||
private ContentTrustService? _trust;
|
||||
private Task<CatalogDocument?>? _catalogFetch;
|
||||
private string _launcherPath = "";
|
||||
private Version _version = new(1, 0, 0);
|
||||
@@ -159,6 +163,10 @@ public partial class App : Application, ILauncherRestartHost
|
||||
}
|
||||
}
|
||||
|
||||
// Signatures du contenu (phase 7) : trousseau téléchargé dès maintenant, en parallèle du catalogue.
|
||||
_trust = CreateContentTrust(catalog);
|
||||
if (_trust is not null && args.CaptureDirectory is null && !args.Uninstall) _ = _trust.GetKeyringAsync();
|
||||
|
||||
// Catalogue des jeux (informations modifiables publiées depuis le menu d'administration) :
|
||||
// dernier catalogue en cache appliqué tout de suite ; version à jour téléchargée pendant la
|
||||
// vérification de mise à jour du launcher (pas de téléchargement pour --capture et --uninstall).
|
||||
@@ -294,7 +302,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
var settingsPath = catalog.GetGameSettingsPath(config); // GameLauncher\Games\<GameId>\settings.json
|
||||
var settings = LauncherSettings.Load(settingsPath);
|
||||
var installer = new GameInstaller(_http!);
|
||||
var manifestClient = new ManifestClient(_http!, new Uri(config.ManifestUrl));
|
||||
var manifestClient = new ManifestClient(_http!, new Uri(config.ManifestUrl), _trust, config.GameId);
|
||||
|
||||
// Si le launcher a été déplacé depuis l'installation du jeu, le bouton « Désinstaller »
|
||||
// de Windows doit pointer vers son nouvel emplacement. (Pas en mode --capture.)
|
||||
@@ -371,7 +379,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
if (url is null || _http is null) return;
|
||||
try
|
||||
{
|
||||
_catalogService = new CatalogService(_http, catalog.DataDirectory, new Uri(url));
|
||||
_catalogService = new CatalogService(_http, catalog.DataDirectory, new Uri(url), _trust);
|
||||
if (_catalogService.LoadCached() is { } cached) ApplyCatalog(catalog, cached, args);
|
||||
if (download) _catalogFetch = _catalogService.FetchAsync();
|
||||
}
|
||||
@@ -453,6 +461,56 @@ public partial class App : Application, ILauncherRestartHost
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Signatures du contenu (phase 7)
|
||||
|
||||
/// <summary>
|
||||
/// Vérification des signatures du manifest et du catalogue (« keyringUrl », « contentSigning »), avec la ou les
|
||||
/// clés racine intégrées à l'exe (Security\update-signing.pub). Null : mode « off ».
|
||||
/// </summary>
|
||||
private ContentTrustService? CreateContentTrust(LauncherCatalog catalog)
|
||||
{
|
||||
var (keyringUrl, mode, minSequence) = ResolveContentSigning(catalog);
|
||||
if (mode == ContentSigningMode.Off) return null;
|
||||
var roots = LauncherUpdateService.LoadRootKeys();
|
||||
if (roots.Count == 0)
|
||||
Log.Error("Signatures du contenu : aucune clé racine intégrée (Security\\update-signing.pub) : aucun trousseau ne peut être vérifié.");
|
||||
Log.Info($"Signatures du contenu : mode « {mode.ToString().ToLowerInvariant()} », trousseau {keyringUrl ?? "non configuré"}" +
|
||||
(minSequence > 0 ? $" (n° {minSequence} au minimum)." : "."));
|
||||
return new ContentTrustService(_http!.GetBytesAsync, catalog.DataDirectory, keyringUrl is null ? null : new Uri(keyringUrl), roots, mode,
|
||||
minSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// « keyringUrl », « contentSigning » et « keyringMinSequence ». Comme apiUrl : en Release, seulement ceux du
|
||||
/// launcher.json intégré à l'exécutable (un fichier posé à côté ne peut ni désactiver la vérification, ni désigner
|
||||
/// un autre trousseau, ni abaisser le numéro minimal).
|
||||
/// </summary>
|
||||
private static (string? KeyringUrl, ContentSigningMode Mode, long MinSequence) ResolveContentSigning(LauncherCatalog catalog)
|
||||
{
|
||||
#if DEBUG
|
||||
if (catalog.KeyringUrlError is not null) Log.Warn(catalog.KeyringUrlError);
|
||||
if (catalog.ContentSigningError is not null) Log.Warn(catalog.ContentSigningError);
|
||||
return (catalog.KeyringUrl, catalog.ContentSigning, catalog.KeyringMinSequence);
|
||||
#else
|
||||
try
|
||||
{
|
||||
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
|
||||
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
|
||||
if ((catalog.KeyringUrl is not null && !string.Equals(catalog.KeyringUrl, integrated?.KeyringUrl, StringComparison.Ordinal)) ||
|
||||
(catalog.KeyringUrl is not null && catalog.ContentSigning != integrated?.ContentSigning))
|
||||
Log.Warn("« keyringUrl » / « contentSigning » du launcher.json externe ignorés : seuls ceux intégrés à l'exécutable sont utilisés.");
|
||||
if (integrated?.KeyringUrlError is { } urlError) Log.Warn(urlError);
|
||||
if (integrated?.ContentSigningError is { } modeError) Log.Warn(modeError);
|
||||
return integrated is null ? (null, ContentSigningMode.Off, 0) : (integrated.KeyringUrl, integrated.ContentSigning, integrated.KeyringMinSequence);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Signatures du contenu (launcher.json intégré)", ex);
|
||||
return (null, ContentSigningMode.Off, 0);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adresse du catalogue (« catalogUrl »). Comme apiUrl : en Release, seulement celle du
|
||||
/// launcher.json intégré à l'exécutable ; en Debug, celle de la configuration utilisée.
|
||||
@@ -511,7 +569,9 @@ 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>1.3.0</Version>
|
||||
<Version>2.2.0</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
|
||||
@@ -458,6 +458,10 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
|
||||
// ------------------------------------------------------------------ Utilitaires
|
||||
|
||||
/// <summary>Clés racine (identifiant, SPKI) : trousseau du contenu signé (phase 7), vérifié avec les mêmes clés.</summary>
|
||||
internal static IReadOnlyList<(string KeyId, byte[] PublicKey)> LoadRootKeys() =>
|
||||
LoadTrustedKeys().Select(k => (k.KeyId, k.SubjectPublicKeyInfo)).ToList();
|
||||
|
||||
private static IReadOnlyList<UpdateSignature.TrustedKey> LoadTrustedKeys()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -84,7 +84,8 @@ public sealed class AdminWindowService : IDisposable
|
||||
Ui = new WpfUiDispatcher(_owner.Dispatcher),
|
||||
Tpm = new TpmKeyStore(), // clés dans la puce de sécurité : proposées après vérification réelle de la puce
|
||||
SettingsPath = _settingsPath,
|
||||
LauncherVersion = _launcherVersion
|
||||
LauncherVersion = _launcherVersion,
|
||||
RootKeys = LauncherUpdateService.LoadRootKeys()
|
||||
});
|
||||
window = new AdminWindow(shell);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
|
||||
@@ -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)
|
||||
|
||||
+637
-118
@@ -6,7 +6,9 @@
|
||||
xmlns:achl="clr-namespace:GameLauncher.Modules.Admin.Changelog;assembly=GameLauncher.Modules.Admin"
|
||||
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:aaud="clr-namespace:GameLauncher.Modules.Admin.Audit;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.
|
||||
@@ -128,6 +130,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" />
|
||||
@@ -196,7 +233,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}" />
|
||||
@@ -423,15 +461,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">
|
||||
@@ -463,7 +492,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>
|
||||
@@ -544,10 +574,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"
|
||||
@@ -564,26 +592,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>
|
||||
|
||||
@@ -597,7 +605,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}"
|
||||
@@ -620,9 +630,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>
|
||||
@@ -670,7 +684,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>
|
||||
|
||||
@@ -723,7 +752,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>
|
||||
@@ -839,30 +869,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>
|
||||
|
||||
@@ -1076,8 +1088,19 @@
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="14,0,0,0">
|
||||
<TextBlock Text="Dossier de publication" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Text="{Binding PublishedText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12" />
|
||||
<DockPanel>
|
||||
<TextBlock Text="{Binding PublishedText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
|
||||
<!-- Signature de la version en ligne (phase 7) -->
|
||||
<StackPanel Orientation="Horizontal" Margin="0,4,0,0"
|
||||
Visibility="{Binding HasOnlineSignatureText, Converter={StaticResource BoolToVisibility}}">
|
||||
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0"
|
||||
Visibility="{Binding IsOnlineSigned, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0"
|
||||
Visibility="{Binding IsOnlineSigned, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<TextBlock Text="{Binding OnlineSignatureText}" Style="{StaticResource AdminHintStyle}" Margin="8,0,0,0" MaxWidth="600" />
|
||||
</StackPanel>
|
||||
<DockPanel Margin="0,12,0,0">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" Margin="0,0,6,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" ToolTip="Contrôler de nouveau le dossier"
|
||||
@@ -1089,6 +1112,17 @@
|
||||
Text="{Binding ManifestPath, TargetNullValue='Choisissez le manifest.json produit par le .bat de publication (dossier --output).'}"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</DockPanel>
|
||||
<!-- Version en ligne identique au dossier choisi : signature sans renvoi -->
|
||||
<DockPanel Margin="0,12,0,0" Visibility="{Binding CanSignOnline, Converter={StaticResource BoolToVisibility}}">
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Signer la version en ligne" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.ShieldCheck}" Command="{Binding SignOnlineCommand}"
|
||||
ToolTip="Ajoute votre signature au manifest en ligne (le serveur vérifie que son contenu est celui de ce dossier)" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
|
||||
Text="Ce dossier correspond exactement à la version en ligne : vous pouvez la signer sans la renvoyer." />
|
||||
</DockPanel>
|
||||
<TextBlock Text="{Binding OnlineMismatchText}" Style="{StaticResource AdminHintStyle}" Margin="0,10,0,0"
|
||||
Foreground="{DynamicResource WarningBrush}"
|
||||
Visibility="{Binding OnlineMismatchText, Converter={StaticResource StringToVisibility}}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -1137,34 +1171,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>
|
||||
|
||||
@@ -1345,15 +1351,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>
|
||||
|
||||
@@ -1494,15 +1491,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>
|
||||
|
||||
@@ -1589,19 +1577,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>
|
||||
|
||||
@@ -1620,6 +1631,514 @@
|
||||
Visibility="{Binding CanLoadMore, Converter={StaticResource BoolToVisibility}}" />
|
||||
<TextBlock Text="{Binding LastRefresh}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ Page « Signature » (phase 7) -->
|
||||
|
||||
<!-- Clé du trousseau proposé -->
|
||||
<DataTemplate DataType="{x:Type asig:KeyringKeyItem}">
|
||||
<Border x:Name="Row" CornerRadius="10" Padding="12,8" Margin="0,0,0,6" BorderThickness="1"
|
||||
Background="{DynamicResource SurfaceAltBrush}" BorderBrush="{DynamicResource BorderBrush}">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Right" Style="{StaticResource AdminChipStyle}" Margin="12,0,0,0" Padding="8,1" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="ChangeText" Text="{Binding ChangeText}" FontSize="10.5" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</Border>
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.KeyVariant}"
|
||||
VerticalAlignment="Top" Margin="0,2,0,0" />
|
||||
<StackPanel Margin="12,0,0,0">
|
||||
<TextBlock Text="{Binding Name}" FontSize="12.5" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock FontSize="11.5" Margin="0,2,0,0" Foreground="{DynamicResource TextMutedBrush}" TextWrapping="Wrap">
|
||||
<Run Text="{Binding ScopesText, Mode=OneWay}" />
|
||||
<Run Text="· clé" />
|
||||
<Run Text="{Binding KeyId, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsAdded}" Value="True">
|
||||
<Setter TargetName="Row" Property="BorderBrush" Value="{DynamicResource SuccessBrush}" />
|
||||
<Setter TargetName="ChangeText" Property="Foreground" Value="{DynamicResource SuccessBrush}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsRemoved}" Value="True">
|
||||
<Setter TargetName="Row" Property="BorderBrush" Value="{DynamicResource DangerBrush}" />
|
||||
<Setter TargetName="ChangeText" Property="Foreground" Value="{DynamicResource DangerBrush}" />
|
||||
<Setter TargetName="Row" Property="Opacity" Value="0.75" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsChanged}" Value="True">
|
||||
<Setter TargetName="Row" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
|
||||
<Setter TargetName="ChangeText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Contenu en ligne et sa signature -->
|
||||
<DataTemplate DataType="{x:Type asig:SignedContentItem}">
|
||||
<DockPanel Margin="0,0,0,10">
|
||||
<Path x:Name="Icon" Width="15" Height="15" Stretch="Uniform" VerticalAlignment="Top" Margin="0,2,0,0"
|
||||
Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}" />
|
||||
<StackPanel Margin="12,0,0,0">
|
||||
<TextBlock Text="{Binding Title}" FontSize="12.5" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="Text" Text="{Binding Text}" FontSize="12" Margin="0,2,0,0" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding WillBreak}" Value="True">
|
||||
<Setter TargetName="Icon" Property="Fill" Value="{DynamicResource WarningBrush}" />
|
||||
<Setter TargetName="Icon" Property="Data" Value="{DynamicResource Icon.Alert}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsValid}" Value="False">
|
||||
<Setter TargetName="Icon" Property="Fill" Value="{DynamicResource DangerBrush}" />
|
||||
<Setter TargetName="Icon" Property="Data" Value="{DynamicResource Icon.AlertCircle}" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type asig:SigningPanelViewModel}">
|
||||
<StackPanel>
|
||||
<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>
|
||||
<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>
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
|
||||
Visibility="{Binding ShowPublishingDisabled, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock TextWrapping="Wrap" FontSize="12.5"
|
||||
Text="Publication du trousseau non configurée sur le serveur : renseignez LauncherApi:Catalog:PublicFile (le trousseau est alors écrit à côté du catalogue) ou LauncherApi:Keyring:PublicFile (server/deploy/LISEZMOI.md, étape 3 quinquies)." />
|
||||
</Border>
|
||||
|
||||
<!-- Trousseau publié -->
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Style="{StaticResource AdminIconBadgeStyle}">
|
||||
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.ShieldKey}" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="14,0,0,0">
|
||||
<TextBlock Text="Trousseau publié" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Text="{Binding KeyringText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
|
||||
<StackPanel Orientation="Horizontal" Margin="0,6,0,0" Visibility="{Binding HasKeyring, Converter={StaticResource BoolToVisibility}}">
|
||||
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0" Visibility="{Binding RootValid, Converter={StaticResource BoolToVisibility}}" />
|
||||
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
|
||||
VerticalAlignment="Top" Margin="0,1,0,0"
|
||||
Visibility="{Binding RootValid, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<TextBlock Text="{Binding RootText}" FontSize="12" Margin="8,0,0,0" TextWrapping="Wrap" MaxWidth="600" />
|
||||
</StackPanel>
|
||||
<TextBlock FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
Visibility="{Binding KeyringUrl, Converter={StaticResource StringToVisibility}}">
|
||||
<Run Text="Adresse à mettre dans « keyringUrl » de launcher.json :" />
|
||||
<Run Text="{Binding KeyringUrl, Mode=OneWay}" FontFamily="Cascadia Mono, Consolas, Courier New" />
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding RequireText}" Style="{StaticResource AdminHintStyle}" Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Trousseau à jour -->
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Clés autorisées à signer" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
|
||||
Text="D'après les appareils actifs des administrateurs autorisés à publier une version (propriétaires, administrateurs) ou le catalogue (éditeurs compris). Le trousseau est public : chacun peut y lire le nom des appareils (« Nom — appareil »)." />
|
||||
<TextBlock Text="{Binding ChangesText}" FontSize="12.5" FontWeight="SemiBold" Margin="0,0,0,10" />
|
||||
<ItemsControl ItemsSource="{Binding Keys}" Focusable="False" />
|
||||
<DockPanel Margin="0,10,0,0">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Exporter la demande…" Margin="0,0,8,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding ExportRequestCommand}"
|
||||
ToolTip="Enregistre le trousseau proposé, à signer hors ligne avec tools\sign-keyring.bat" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Publier le trousseau signé…"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.CloudUpload}" Command="{Binding PublishSignedCommand}"
|
||||
ToolTip="Vérifie la signature racine avec la clé intégrée à ce launcher, puis publie" />
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center"
|
||||
Text="1. Exporter la demande. 2. La signer avec tools\sign-keyring.bat (clé racine, hors ligne). 3. Publier le trousseau signé." />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Contenu en ligne -->
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Contenu en ligne" Style="{StaticResource AdminCardTitleStyle}" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
|
||||
Text="Versions et catalogue tels que les launchers les reçoivent, vérifiés avec le trousseau publié et avec le trousseau proposé." />
|
||||
<ItemsControl ItemsSource="{Binding Contents}" Focusable="False" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ 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" />
|
||||
</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"
|
||||
Text="La clé racine (launcher-signing.key) n'est jamais ouverte par le launcher ni envoyée au serveur." />
|
||||
</DockPanel>
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
|
||||
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -6,6 +6,7 @@ using System.Net.Http;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Mvvm;
|
||||
using GameLauncher.Services;
|
||||
@@ -519,11 +520,15 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
|
||||
// ------------------------------------------------------------------ Mises à jour
|
||||
|
||||
/// <summary>Raison du refus du dernier manifest (signature, phase 7), sinon null.</summary>
|
||||
private string? _manifestRefusal;
|
||||
|
||||
private async Task<GameManifest?> TryFetchManifestAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifest = await _manifestClient.FetchAsync(_lifetime.Token);
|
||||
_manifestRefusal = null;
|
||||
if (!string.IsNullOrEmpty(manifest.GameId) &&
|
||||
!string.Equals(manifest.GameId, _config.GameId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -542,12 +547,32 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (ContentSecurityException ex)
|
||||
{
|
||||
Log.Error("Impossible de récupérer le manifest", ex);
|
||||
// Signature absente ou invalide (contentSigning « require ») : rien n'est installé ni mis à jour.
|
||||
_manifestRefusal = ex.Message;
|
||||
IsOffline = true;
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Impossible de récupérer le manifest", ex);
|
||||
_manifestRefusal = null;
|
||||
IsOffline = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Manifest indisponible : refus de sécurité (signature) expliqué tel quel, sinon serveur injoignable.</summary>
|
||||
private void ShowManifestUnavailable(string offlineMessage, DialogKind kind = DialogKind.Error)
|
||||
{
|
||||
if (_manifestRefusal is { } refusal)
|
||||
_dialogs.Show("Mise à jour bloquée par sécurité",
|
||||
"Les informations publiées sur le serveur n'ont pas une signature valide : elles sont refusées pour protéger votre PC.\n\n" +
|
||||
$"{refusal}\n\nVous pouvez continuer à jouer à la version installée. Si le problème persiste, prévenez l'éditeur du jeu.",
|
||||
DialogKind.Error);
|
||||
else
|
||||
_dialogs.Show("Serveur injoignable", offlineMessage, kind);
|
||||
}
|
||||
|
||||
private async Task CheckForUpdatesAsync(bool userInitiated, bool background = false)
|
||||
@@ -579,10 +604,15 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
if (userInitiated)
|
||||
{
|
||||
_dialogs.Show("Serveur injoignable",
|
||||
"Impossible de contacter le serveur de mises à jour. Vérifiez votre connexion Internet puis réessayez.",
|
||||
ShowManifestUnavailable("Impossible de contacter le serveur de mises à jour. Vérifiez votre connexion Internet puis réessayez.",
|
||||
DialogKind.Warning);
|
||||
}
|
||||
else if (!background && State != LauncherState.Broken && _manifestRefusal is not null)
|
||||
{
|
||||
ShowBanner("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, autoHide: false);
|
||||
}
|
||||
else if (!background && State != LauncherState.Broken)
|
||||
{
|
||||
ShowBanner("Mode hors ligne",
|
||||
@@ -699,9 +729,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
if (manifest is null)
|
||||
{
|
||||
RefreshInstallState();
|
||||
_dialogs.Show("Serveur injoignable",
|
||||
"Impossible de récupérer les informations du jeu. Vérifiez votre connexion Internet puis réessayez.",
|
||||
DialogKind.Error);
|
||||
ShowManifestUnavailable("Impossible de récupérer les informations du jeu. Vérifiez votre connexion Internet puis réessayez.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -735,9 +763,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
if (manifest is null)
|
||||
{
|
||||
RefreshInstallState();
|
||||
_dialogs.Show("Serveur injoignable",
|
||||
"Impossible de télécharger la mise à jour. Vérifiez votre connexion Internet puis réessayez.",
|
||||
DialogKind.Error);
|
||||
ShowManifestUnavailable("Impossible de télécharger la mise à jour. Vérifiez votre connexion Internet puis réessayez.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -772,9 +798,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
|
||||
if (manifest is null)
|
||||
{
|
||||
RefreshInstallState();
|
||||
_dialogs.Show("Serveur injoignable",
|
||||
"La réparation nécessite une connexion au serveur. Vérifiez votre connexion Internet puis réessayez.",
|
||||
DialogKind.Error);
|
||||
ShowManifestUnavailable("La réparation nécessite une connexion au serveur. Vérifiez votre connexion Internet puis réessayez.");
|
||||
ReportLinksOnly();
|
||||
return;
|
||||
}
|
||||
@@ -863,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)
|
||||
{
|
||||
@@ -1407,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"
|
||||
|
||||
@@ -37,6 +37,15 @@
|
||||
// Supprimez la ligne pour n'utiliser que les valeurs de ce fichier.
|
||||
"catalogUrl": "https://launcher.lanisys.fr/catalog/catalog.json",
|
||||
|
||||
// Signature du contenu (phase 7) : trousseau des clés autorisées à signer versions et catalogue, lui-même
|
||||
// signé par la clé de mise à jour du launcher (Security\update-signing.pub). « warn » : problèmes inscrits
|
||||
// au journal ; « require » : contenu non signé refusé. Comme apiUrl, lus UNIQUEMENT dans le launcher.json
|
||||
// intégré à l'exe. À décommenter une fois le trousseau publié (server/deploy/LISEZMOI.md, étape 3 quinquies).
|
||||
// « keyringMinSequence » : numéro minimal du trousseau accepté, à relever après le retrait d'une clé compromise.
|
||||
"keyringUrl": "https://launcher.lanisys.fr/catalog/keyring.json",
|
||||
"contentSigning": "require",
|
||||
"keyringMinSequence": 1,
|
||||
|
||||
"websiteUrl": "https://lanisys.fr",
|
||||
|
||||
// Thème du launcher pour ce jeu : "default" (violet/cyan) ou "exovival" (couloir sombre, vert).
|
||||
|
||||
@@ -132,6 +132,23 @@ public class AdminLoginAndEnrollmentTests
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.LoginAsync(folder.File("launcher-signing - Copie.key"), AdminTestKit.Passphrase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_launcher_root_key_is_refused_whatever_the_file_name()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var folder = new TempFolder();
|
||||
// Clé racine renommée « ma-cle.key » (et même enregistrée comme administrateur) : jamais utilisée pour se connecter.
|
||||
var (path, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
var root = (signer.KeyId, signer.PublicKey);
|
||||
using (signer) Owner(api, signer);
|
||||
|
||||
var refused = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
new AdminLoginService(api.CreateClient(), "tests", rootKeys: new[] { root }).LoginAsync(path, AdminTestKit.Passphrase));
|
||||
Assert.Contains("clé racine", refused.Message);
|
||||
await using var normal = await new AdminLoginService(api.CreateClient(), "tests").LoginAsync(path, AdminTestKit.Passphrase);
|
||||
Assert.Equal(normal.Fingerprint, normal.Session.LocalFingerprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_key_gets_a_clear_message()
|
||||
{
|
||||
|
||||
@@ -84,8 +84,10 @@ public static class AdminTestKit
|
||||
|
||||
public static AdminEnvironment Environment(TestApi api, string folder, FakeDialogs dialogs, ModuleCatalog? modules = null,
|
||||
IAdminTpm? tpm = null, IReadOnlyList<LauncherConfig>? games = null,
|
||||
IReadOnlyList<string>? themes = null) => new()
|
||||
IReadOnlyList<string>? themes = null,
|
||||
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null) => new()
|
||||
{
|
||||
RootKeys = rootKeys ?? Array.Empty<(string, byte[])>(),
|
||||
Tpm = tpm,
|
||||
Client = api.CreateClient(),
|
||||
Modules = modules ?? new ModuleCatalog(new ILauncherModule[] { new AdminModule() }),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,142 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Publishing;
|
||||
using GameLauncher.Modules.Admin.Signing;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Publishing;
|
||||
using GameLauncher.Tests.Server;
|
||||
using GameLauncher.Tests.Signing;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
/// <summary>Pages « Signature » et « Publier une version » (signature de la version en ligne).</summary>
|
||||
public class SigningPanelTests
|
||||
{
|
||||
private static readonly ModuleCatalog Modules =
|
||||
new(new ILauncherModule[] { new AdminModule(), new SigningAdminModule(), new PublishAdminModule() });
|
||||
|
||||
[Fact]
|
||||
public async Task The_owner_exports_the_keyring_request_then_publishes_it_once_signed_by_the_root_key()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-signing-tests", Guid.NewGuid().ToString("N"), "catalog");
|
||||
Directory.CreateDirectory(directory);
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
|
||||
{
|
||||
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json"),
|
||||
["LauncherApi:Games:EZR:ManifestFile"] = manifest
|
||||
});
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
using var root = SigningKit.NewKey();
|
||||
using var forgedRoot = SigningKit.NewKey();
|
||||
var dialogs = new FakeDialogs();
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules,
|
||||
rootKeys: new[] { SigningKit.Root(root) }));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(SigningPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
var page = Assert.IsType<SigningPanelViewModel>(workspace.SelectedPanel!.ViewModel);
|
||||
|
||||
// Aucun trousseau : la clé du propriétaire est à ajouter ; la version en ligne n'est pas signée.
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.False(page.HasKeyring);
|
||||
var key = Assert.Single(page.Keys);
|
||||
Assert.True(key.IsAdded);
|
||||
Assert.Equal(signer.KeyId, key.KeyId);
|
||||
Assert.Contains("versions du jeu", key.ScopesText);
|
||||
Assert.Contains(page.Contents, c => c.Title.Contains("EZR") && c.Text.StartsWith("Non signé"));
|
||||
|
||||
// Export de la demande (contenu non signé : confirmation demandée).
|
||||
var request = folder.File("keyring-request.json");
|
||||
dialogs.NextSaveFile = request;
|
||||
await page.ExportRequestCommand.ExecuteAsync();
|
||||
Assert.Contains(dialogs.Confirmations, c => c.StartsWith("Contenu à signer d'abord"));
|
||||
var exported = Keyring.Parse(File.ReadAllBytes(request))!;
|
||||
Assert.Equal(1, exported.Sequence);
|
||||
Assert.Null(exported.Signature);
|
||||
Assert.Equal(signer.KeyId, Assert.Single(exported.Keys).KeyId);
|
||||
|
||||
// Trousseau signé par une autre racine : refusé avant tout envoi.
|
||||
var forged = folder.File("forged.json");
|
||||
File.WriteAllText(forged, Keyring.Serialize(Keyring.Sign(exported, forgedRoot)));
|
||||
dialogs.NextOpenFile = forged;
|
||||
await page.PublishSignedCommand.ExecuteAsync();
|
||||
Assert.Contains("inconnue", page.ErrorMessage);
|
||||
Assert.False(page.HasKeyring);
|
||||
|
||||
// Signé hors ligne par la vraie racine (tools\sign-keyring.bat) : publié.
|
||||
var signed = folder.File("keyring.json");
|
||||
File.WriteAllText(signed, Keyring.Serialize(Keyring.Sign(exported, root)));
|
||||
dialogs.NextOpenFile = signed;
|
||||
await page.PublishSignedCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.True(page.HasKeyring);
|
||||
Assert.True(page.RootValid);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.False(page.NeedsNewKeyring);
|
||||
Assert.False(page.ExportRequestCommand.CanExecute(null));
|
||||
Assert.Contains("n° 1", page.StatusMessage);
|
||||
Assert.True(File.Exists(Path.Combine(directory, "keyring.json")));
|
||||
|
||||
// Le même trousseau une seconde fois : refusé (numéro déjà publié).
|
||||
await page.PublishSignedCommand.ExecuteAsync();
|
||||
Assert.Contains("déjà publié", page.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_names_proposed_for_the_public_keyring_are_cleaned()
|
||||
{
|
||||
// Caractères invisibles ou d'inversion du sens d'écriture retirés : le nom reste acceptable par les launchers.
|
||||
var name = SigningPanelViewModel.Truncate("Nicolas — \u202EPC\u200B fixe\n");
|
||||
Assert.Equal("Nicolas — PC fixe", name);
|
||||
Assert.True(Keyring.IsDisplaySafe(name));
|
||||
Assert.Equal(Keyring.MaxNameLength, SigningPanelViewModel.Truncate(new string('a', 500)).Length);
|
||||
Assert.Equal("Appareil", SigningPanelViewModel.Truncate("\u200B"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_version_uploaded_with_winscp_is_signed_from_its_publication_folder()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var release = PublishingTests.ReleaseFolder.Create(version: "1.1", smallFiles: 2, bigFileBytes: 1000, archiveBytes: 500);
|
||||
File.Copy(release.ManifestPath, manifest, overwrite: true);
|
||||
var other = PublishingTests.ReleaseFolder.Create(version: "1.1", smallFiles: 3, bigFileBytes: 1000, archiveBytes: 500);
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
|
||||
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(PublishPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
|
||||
Assert.Contains("non signée", page.OnlineSignatureText);
|
||||
|
||||
// Même numéro, contenu différent : signature impossible avec ce dossier.
|
||||
dialogs.NextOpenFile = other.ManifestPath;
|
||||
await page.ChooseCommand.ExecuteAsync();
|
||||
Assert.False(page.CanSignOnline);
|
||||
Assert.NotNull(page.OnlineMismatchText);
|
||||
|
||||
// Le dossier de la version en ligne : signée sans renvoi.
|
||||
dialogs.NextOpenFile = release.ManifestPath;
|
||||
await page.ChooseCommand.ExecuteAsync();
|
||||
Assert.True(page.CanSignOnline);
|
||||
await page.SignOnlineCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.True(page.IsOnlineSigned);
|
||||
Assert.Contains("signée par", page.OnlineSignatureText);
|
||||
Assert.Equal(signer.KeyId, JsonNode.Parse(File.ReadAllBytes(manifest))!["signature"]!["keyId"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
@@ -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,239 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Modules.Admin.Publishing;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Publishing;
|
||||
using GameLauncher.Tests.Signing;
|
||||
|
||||
namespace GameLauncher.Tests.Server;
|
||||
|
||||
/// <summary>Signature du contenu côté serveur (phase 7) : catalogue, mise en ligne, version en ligne, trousseau.</summary>
|
||||
public class SigningModuleTests
|
||||
{
|
||||
private static string PublicDirectory()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-signing-tests", Guid.NewGuid().ToString("N"), "catalog");
|
||||
Directory.CreateDirectory(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
/// <summary>Trousseau (racine de test) contenant la clé d'un administrateur, pour vérifier comme un launcher.</summary>
|
||||
private static KeyringDocument KeyringOf(IAdminSigner admin, params string[] scopes) =>
|
||||
SigningKit.Keyring(SigningKit.NewKey(), 1, new KeyringKey
|
||||
{
|
||||
KeyId = admin.KeyId,
|
||||
PublicKey = Convert.ToBase64String(admin.PublicKey),
|
||||
Name = "Propriétaire",
|
||||
Scopes = scopes.Length == 0 ? SigningScopes.All.ToList() : scopes.ToList()
|
||||
});
|
||||
|
||||
private static CatalogDocument Catalog(string tagline) => new()
|
||||
{
|
||||
Games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase) { ["EZR"] = new() { Tagline = tagline } }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task The_catalog_is_published_with_the_signature_of_the_session_key()
|
||||
{
|
||||
var publicFile = Path.Combine(PublicDirectory(), "catalog.json");
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
|
||||
var catalog = Catalog("Signé");
|
||||
var signature = await owner.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog));
|
||||
await owner.PublishCatalogAsync(catalog with { Signature = signature }, 0);
|
||||
|
||||
// Le fichier public porte la signature, valide pour un launcher qui a cette clé dans son trousseau.
|
||||
var published = JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(publicFile), ApiJson.Options)!;
|
||||
Assert.Equal(1, published.Revision);
|
||||
var check = ContentSigning.Verify(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(published),
|
||||
published.Signature, KeyringOf(ownerKey));
|
||||
Assert.True(check.Valid, check.Reason);
|
||||
|
||||
// Signature d'un autre contenu, ou d'une autre clé que celle de la session : refusées.
|
||||
var other = await owner.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(Catalog("Autre")));
|
||||
var mismatch = await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(Catalog("Signé 2") with { Signature = other }, 1));
|
||||
Assert.Equal(ApiErrorCodes.SignatureInvalid, mismatch.Code);
|
||||
using var stranger = SigningKit.NewKey();
|
||||
var foreign = SigningKit.Sign(stranger, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(Catalog("Signé 2")));
|
||||
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(Catalog("Signé 2") with { Signature = foreign }, 1))).Status);
|
||||
// Date de signature trafiquée (bien plus tard) : refusée, elle bloquerait les mises à jour suivantes chez les joueurs.
|
||||
var future = SigningKit.Sign(stranger, ContentKinds.Catalog, ContentKinds.CatalogId, "x") with { KeyId = ownerKey.KeyId, SignedAt = DateTimeOffset.UtcNow.AddDays(30) };
|
||||
Assert.Equal(ApiErrorCodes.ClockSkew,
|
||||
(await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(Catalog("Signé 2") with { Signature = future }, 1))).Code);
|
||||
|
||||
// Non signé : accepté tant que le serveur ne l'exige pas.
|
||||
await owner.PublishCatalogAsync(Catalog("Non signé"), 1);
|
||||
Assert.Null(JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(publicFile), ApiJson.Options)!.Signature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task When_required_unsigned_publications_are_refused()
|
||||
{
|
||||
var publicFile = Path.Combine(PublicDirectory(), "catalog.json");
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
|
||||
{
|
||||
["LauncherApi:Catalog:PublicFile"] = publicFile,
|
||||
["LauncherApi:ContentSigning:Require"] = "true"
|
||||
});
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(Catalog("Non signé"), 0));
|
||||
Assert.Equal(ApiErrorCodes.SignatureRequired, refused.Code);
|
||||
Assert.False(File.Exists(publicFile));
|
||||
Assert.True((await owner.GetSigningStateAsync()).RequireSignatures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_published_version_carries_a_signature_that_survives_note_corrections()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 3, bigFileBytes: 100_000, archiveBytes: 5000);
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
|
||||
await ReleaseUploader.PublishAsync(owner, "EZR", ReleaseUploader.Inspect(release.ManifestPath, "EZR"), null, null, default);
|
||||
|
||||
ContentCheck Check()
|
||||
{
|
||||
var bytes = File.ReadAllBytes(manifest);
|
||||
var signature = JsonNode.Parse(bytes)!["signature"]!.Deserialize<ContentSignature>(ApiJson.Options);
|
||||
return ContentSigning.Verify(ContentKinds.Manifest, "EZR", ContentSigning.ManifestDigest(bytes), signature, KeyringOf(ownerKey));
|
||||
}
|
||||
var check = Check();
|
||||
Assert.True(check.Valid, check.Reason);
|
||||
|
||||
// Correction des notes en ligne : la signature reste valide (les notes n'en font pas partie).
|
||||
var state = await owner.GetChangelogAsync("EZR");
|
||||
var entry = state.Entries.First(e => e.Version == "1.2");
|
||||
entry.Title = "Version 1.2 (corrigée)";
|
||||
await owner.SaveChangelogEntryAsync("EZR", entry, state.ManifestSha256);
|
||||
Assert.Contains("corrigée", File.ReadAllText(manifest));
|
||||
check = Check();
|
||||
Assert.True(check.Valid, check.Reason);
|
||||
|
||||
var online = await owner.GetManifestSignatureAsync("EZR");
|
||||
Assert.Equal("1.2", online.Version);
|
||||
Assert.Equal(ownerKey.KeyId, online.Signature!.KeyId);
|
||||
Assert.StartsWith("Propriétaire", online.SignerName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_online_version_is_signed_only_from_a_folder_with_the_same_content()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var release = PublishingTests.ReleaseFolder.Create(version: "1.1", smallFiles: 2, bigFileBytes: 1000, archiveBytes: 500);
|
||||
File.Copy(release.ManifestPath, manifest, overwrite: true); // version mise en ligne avec WinSCP, non signée
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
|
||||
|
||||
var online = await owner.GetManifestSignatureAsync("EZR");
|
||||
Assert.Null(online.Signature);
|
||||
Assert.Equal(local.ContentDigest, online.Digest);
|
||||
|
||||
// Dossier d'une autre version : refusé (le serveur compare le contenu).
|
||||
var otherDigest = ContentSigning.ManifestDigest(Encoding.UTF8.GetBytes(SigningKit.ManifestJson("9.9")));
|
||||
var otherSignature = await owner.SignContentAsync(ContentKinds.Manifest, "EZR", otherDigest);
|
||||
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.SignOnlineManifestAsync("EZR", new ManifestSignRequest { Digest = otherDigest, Signature = otherSignature }))).Status);
|
||||
// Bon condensé, mais signature d'autre chose : refusée.
|
||||
Assert.Equal(ApiErrorCodes.SignatureInvalid, (await Assert.ThrowsAsync<ApiException>(() =>
|
||||
owner.SignOnlineManifestAsync("EZR", new ManifestSignRequest { Digest = local.ContentDigest, Signature = otherSignature }))).Code);
|
||||
Assert.Null(JsonNode.Parse(File.ReadAllBytes(manifest))!["signature"]);
|
||||
|
||||
var signature = await owner.SignContentAsync(ContentKinds.Manifest, "EZR", local.ContentDigest);
|
||||
var signed = await owner.SignOnlineManifestAsync("EZR", new ManifestSignRequest { Digest = local.ContentDigest, Signature = signature });
|
||||
Assert.Equal(ownerKey.KeyId, signed.Signature!.KeyId);
|
||||
var bytes = File.ReadAllBytes(manifest);
|
||||
var check = ContentSigning.Verify(ContentKinds.Manifest, "EZR", ContentSigning.ManifestDigest(bytes),
|
||||
JsonNode.Parse(bytes)!["signature"]!.Deserialize<ContentSignature>(ApiJson.Options), KeyringOf(ownerKey));
|
||||
Assert.True(check.Valid, check.Reason);
|
||||
|
||||
// Un éditeur (sans releases.publish) ne signe pas de version.
|
||||
var (editorKey, _) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Editor);
|
||||
await using var editor = await api.LoginAsync(editorKey);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => editor.GetManifestSignatureAsync("EZR"))).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Only_a_signed_and_newer_keyring_is_published_next_to_the_catalog()
|
||||
{
|
||||
var directory = PublicDirectory();
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
|
||||
{
|
||||
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json"),
|
||||
["LauncherApi:Games:EZR:ManifestFile"] = manifest
|
||||
});
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
using var root = SigningKit.NewKey();
|
||||
var entry = new KeyringKey
|
||||
{
|
||||
KeyId = ownerKey.KeyId,
|
||||
PublicKey = Convert.ToBase64String(ownerKey.PublicKey),
|
||||
Name = "Propriétaire — PC",
|
||||
Scopes = SigningScopes.All.ToList()
|
||||
};
|
||||
|
||||
var unsigned = new KeyringDocument { Sequence = 1, IssuedAt = DateTimeOffset.UtcNow, Keys = new() { entry } };
|
||||
Assert.Equal(400, (await Assert.ThrowsAsync<ApiException>(() => owner.PublishKeyringAsync(unsigned))).Status);
|
||||
|
||||
await owner.PublishKeyringAsync(SigningKit.Keyring(root, 1, entry));
|
||||
var publicFile = Path.Combine(directory, "keyring.json");
|
||||
var published = Keyring.Parse(File.ReadAllBytes(publicFile))!;
|
||||
Assert.True(Keyring.VerifyRoot(published, new[] { SigningKit.Root(root) }, out var error), error);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead,
|
||||
File.GetUnixFileMode(publicFile));
|
||||
|
||||
// Même numéro ou plus ancien : refusé (un launcher refuserait de revenir en arrière).
|
||||
Assert.Equal(409, (await Assert.ThrowsAsync<ApiException>(() => owner.PublishKeyringAsync(SigningKit.Keyring(root, 1, entry)))).Status);
|
||||
await owner.PublishKeyringAsync(SigningKit.Keyring(root, 2, entry));
|
||||
|
||||
var state = await owner.GetSigningStateAsync();
|
||||
Assert.Equal(2, state.Keyring!.Sequence);
|
||||
Assert.EndsWith("/catalog/keyring.json", state.KeyringPublicUrl);
|
||||
Assert.True(state.KeyringPublishingEnabled);
|
||||
Assert.Equal("1.1", Assert.Single(state.Manifests).Version);
|
||||
Assert.Null(state.Manifests[0].Signature);
|
||||
Assert.NotNull(state.Catalog);
|
||||
Assert.Contains((await owner.GetAuditAsync(10)).Entries, e => e.Action == "keyring.publish" && e.Target == "trousseau n° 2");
|
||||
|
||||
// Réservé aux propriétaires ; clés publiques exposées seulement pour les clés actives.
|
||||
var (adminKey, adminId) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Admin);
|
||||
await using var paul = await api.LoginAsync(adminKey);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.GetSigningStateAsync())).Status);
|
||||
Assert.Equal(403, (await Assert.ThrowsAsync<ApiException>(() => paul.PublishKeyringAsync(SigningKit.Keyring(root, 3, entry)))).Status);
|
||||
var device = await paul.CreateInvitationAsync(new CreateInvitationRequest { ForAdminId = adminId });
|
||||
await api.CreateClient().EnrollAsync(device.Code, TestApi.NewKey(), "nouveau PC");
|
||||
var keys = (await owner.ListAdminsAsync()).Single(a => a.Id == adminId).Keys;
|
||||
Assert.All(keys, k => Assert.Equal(k.Status == AdminStatus.Active, k.PublicKey is not null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_catalog_folder_the_keyring_cannot_be_published()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var ownerKey = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(ownerKey);
|
||||
using var root = SigningKit.NewKey();
|
||||
var keyring = SigningKit.Keyring(root, 1, new KeyringKey
|
||||
{
|
||||
KeyId = ownerKey.KeyId, PublicKey = Convert.ToBase64String(ownerKey.PublicKey), Name = "P", Scopes = new() { SigningScopes.Releases }
|
||||
});
|
||||
Assert.Equal(ApiErrorCodes.NotConfigured, (await Assert.ThrowsAsync<ApiException>(() => owner.PublishKeyringAsync(keyring))).Code);
|
||||
Assert.False((await owner.GetSigningStateAsync()).KeyringPublishingEnabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Tests.Signing;
|
||||
|
||||
/// <summary>Condensés canoniques, signatures du contenu et trousseau (phase 7).</summary>
|
||||
public class ContentSigningTests
|
||||
{
|
||||
private static string Digest(string json) => ContentSigning.ManifestDigest(Encoding.UTF8.GetBytes(json));
|
||||
|
||||
[Fact]
|
||||
public void The_manifest_digest_covers_what_is_installed_and_run_but_not_the_notes()
|
||||
{
|
||||
var json = SigningKit.ManifestJson();
|
||||
var digest = Digest(json);
|
||||
|
||||
// Mise en forme, ordre des fichiers, notes, date, signature : sans effet.
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
var files = node["files"]!.AsArray();
|
||||
var first = files[0]!.DeepClone();
|
||||
files.RemoveAt(0);
|
||||
files.Add(first);
|
||||
node["changelog"] = new JsonArray(new JsonObject { ["version"] = "1.2", ["title"] = "Notes corrigées en ligne" });
|
||||
node["releaseDate"] = "2027-01-01T00:00:00Z";
|
||||
node["signature"] = new JsonObject { ["keyId"] = "0123456789abcdef", ["value"] = "x" };
|
||||
Assert.Equal(digest, Digest(node.ToJsonString(new JsonSerializerOptions { WriteIndented = false })));
|
||||
|
||||
// Ce qui est installé ou exécuté : tout changement change le condensé.
|
||||
Assert.NotEqual(digest, Digest(SigningKit.ManifestJson(executableSha: "bb")));
|
||||
Assert.NotEqual(digest, Digest(SigningKit.ManifestJson(version: "1.3")));
|
||||
foreach (var (path, value) in new (string, JsonNode)[]
|
||||
{
|
||||
("executable", "Data/level1.pak"), ("launchArguments", "-cheat"), ("objectsBaseUrl", "https://ailleurs.example/objects/"),
|
||||
("gameId", "AUTRE")
|
||||
})
|
||||
{
|
||||
var changed = JsonNode.Parse(json)!.AsObject();
|
||||
changed[path] = value;
|
||||
Assert.NotEqual(digest, Digest(changed.ToJsonString()));
|
||||
}
|
||||
var archive = JsonNode.Parse(json)!.AsObject();
|
||||
archive["archive"]!["url"] = "https://ailleurs.example/piege.zip";
|
||||
Assert.NotEqual(digest, Digest(archive.ToJsonString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_player_computes_the_same_digest_from_its_own_manifest_model()
|
||||
{
|
||||
// Le launcher des joueurs lit le manifest avec son propre modèle (GameManifest) : même condensé.
|
||||
var json = SigningKit.ManifestJson();
|
||||
var manifest = JsonSerializer.Deserialize<GameManifest>(json, JsonDefaults.Options)!;
|
||||
Assert.Equal(Digest(json), ContentSigning.ManifestDigest(manifest.ToSignedContent()));
|
||||
|
||||
// Arguments vides ou absents, base des objets absente ou par défaut : équivalents.
|
||||
var node = JsonNode.Parse(json)!.AsObject();
|
||||
node.Remove("objectsBaseUrl");
|
||||
node["launchArguments"] = "";
|
||||
var variant = JsonSerializer.Deserialize<GameManifest>(node.ToJsonString(), JsonDefaults.Options)!;
|
||||
Assert.Equal(Digest(json), ContentSigning.ManifestDigest(variant.ToSignedContent()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_catalog_digest_ignores_what_the_server_sets()
|
||||
{
|
||||
var catalog = new CatalogDocument
|
||||
{
|
||||
Launcher = new CatalogLauncher { LauncherName = "Lanisys", GameOrder = new() { "EZR", "B" } },
|
||||
Games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["EZR"] = new() { GameName = "Exovival", Links = new() { new CatalogLink { Kind = "discord", Url = "https://discord.gg/abc" } } },
|
||||
["B"] = new() { Tagline = "Jeu B" }
|
||||
}
|
||||
};
|
||||
var digest = ContentSigning.CatalogDigest(catalog);
|
||||
var reordered = catalog with
|
||||
{
|
||||
Revision = 42,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
Signature = new ContentSignature { KeyId = "0123456789abcdef" },
|
||||
Games = new Dictionary<string, CatalogGame> { ["B"] = catalog.Games["B"], ["EZR"] = catalog.Games["EZR"] }
|
||||
};
|
||||
Assert.Equal(digest, ContentSigning.CatalogDigest(reordered));
|
||||
Assert.False(ContentSigning.HasDuplicateGames(catalog));
|
||||
|
||||
// Identifiants tels que publiés (pas de mise en minuscules) : deux entrées ne différant que par la casse ne
|
||||
// peuvent pas se confondre dans le condensé, et un tel catalogue est repéré (refusé par les launchers).
|
||||
Assert.NotEqual(digest, ContentSigning.CatalogDigest(catalog with
|
||||
{
|
||||
Games = new Dictionary<string, CatalogGame> { ["b"] = catalog.Games["B"], ["ezr"] = catalog.Games["EZR"] }
|
||||
}));
|
||||
var duplicated = catalog with
|
||||
{
|
||||
Games = new Dictionary<string, CatalogGame>(StringComparer.Ordinal)
|
||||
{
|
||||
["EZR"] = catalog.Games["EZR"], ["ezr"] = new() { WebsiteUrl = "https://launcher.example/piege" }, ["B"] = catalog.Games["B"]
|
||||
}
|
||||
};
|
||||
Assert.True(ContentSigning.HasDuplicateGames(duplicated));
|
||||
|
||||
// Lien modifié (hameçonnage), ordre des jeux : refusés.
|
||||
var phishing = catalog with
|
||||
{
|
||||
Games = new Dictionary<string, CatalogGame>(catalog.Games)
|
||||
{
|
||||
["EZR"] = catalog.Games["EZR"] with { Links = new() { new CatalogLink { Kind = "discord", Url = "https://discord.gg/piege" } } }
|
||||
}
|
||||
};
|
||||
Assert.NotEqual(digest, ContentSigning.CatalogDigest(phishing));
|
||||
Assert.NotEqual(digest, ContentSigning.CatalogDigest(catalog with { Launcher = catalog.Launcher with { GameOrder = new() { "B", "EZR" } } }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_signature_is_only_valid_for_its_content_kind_game_and_authorized_key()
|
||||
{
|
||||
using var root = SigningKit.NewKey();
|
||||
using var publisher = SigningKit.NewKey();
|
||||
using var editor = SigningKit.NewKey();
|
||||
using var stranger = SigningKit.NewKey();
|
||||
var keyring = SigningKit.Keyring(root, 1,
|
||||
SigningKit.Entry(publisher, "Nicolas — PC fixe", SigningScopes.Releases, SigningScopes.Catalog),
|
||||
SigningKit.Entry(editor, "Paul — portable", SigningScopes.Catalog));
|
||||
var digest = Digest(SigningKit.ManifestJson());
|
||||
|
||||
var good = SigningKit.Sign(publisher, ContentKinds.Manifest, "EZR", digest);
|
||||
var check = ContentSigning.Verify(ContentKinds.Manifest, "ezr", digest, good, keyring); // identifiant : casse indifférente
|
||||
Assert.True(check.Valid, check.Reason);
|
||||
Assert.Equal("Nicolas — PC fixe", check.Key!.Name);
|
||||
|
||||
Assert.False(ContentSigning.Verify(ContentKinds.Manifest, "EZR", digest, null, keyring).Valid);
|
||||
Assert.Contains("SIGNATURE INVALIDE", ContentSigning.Verify(ContentKinds.Manifest, "EZR", Digest(SigningKit.ManifestJson(version: "0.9")), good, keyring).Reason);
|
||||
Assert.False(ContentSigning.Verify(ContentKinds.Manifest, "AUTREJEU", digest, good, keyring).Valid); // manifest d'un autre jeu
|
||||
Assert.False(ContentSigning.Verify(ContentKinds.Catalog, ContentKinds.CatalogId, digest, good, keyring).Valid); // autre type
|
||||
Assert.Contains("n'est pas autorisée",
|
||||
ContentSigning.Verify(ContentKinds.Manifest, "EZR", digest, SigningKit.Sign(editor, ContentKinds.Manifest, "EZR", digest), keyring).Reason);
|
||||
Assert.Contains("absente du trousseau",
|
||||
ContentSigning.Verify(ContentKinds.Manifest, "EZR", digest, SigningKit.Sign(stranger, ContentKinds.Manifest, "EZR", digest), keyring).Reason);
|
||||
// Date modifiée après signature : refusée (elle fait partie du message signé).
|
||||
Assert.False(ContentSigning.Verify(ContentKinds.Manifest, "EZR", digest, good with { SignedAt = good.SignedAt.AddDays(1) }, keyring).Valid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_keyring_is_trusted_only_when_signed_by_a_root_key_of_the_launcher()
|
||||
{
|
||||
using var root = SigningKit.NewKey();
|
||||
using var otherRoot = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
using var intruder = SigningKit.NewKey();
|
||||
var keyring = SigningKit.Keyring(root, 3, SigningKit.Entry(admin, "Nicolas"));
|
||||
var roots = new[] { SigningKit.Root(root) };
|
||||
|
||||
Assert.True(Keyring.VerifyRoot(keyring, roots, out var error), error);
|
||||
// Relu depuis le fichier publié : toujours valide.
|
||||
var reread = Keyring.Parse(Encoding.UTF8.GetBytes(Keyring.Serialize(keyring)))!;
|
||||
Assert.True(Keyring.VerifyRoot(reread, roots, out error), error);
|
||||
|
||||
// Clé ajoutée par un serveur compromis, numéro modifié, signature d'une autre racine : refusés.
|
||||
Assert.False(Keyring.VerifyRoot(keyring with { Keys = keyring.Keys.Append(SigningKit.Entry(intruder, "Pirate")).ToList() }, roots, out error));
|
||||
Assert.Contains("INVALIDE", error);
|
||||
Assert.False(Keyring.VerifyRoot(keyring with { Sequence = 99 }, roots, out _));
|
||||
Assert.False(Keyring.VerifyRoot(SigningKit.Keyring(otherRoot, 3, SigningKit.Entry(admin, "Nicolas")), roots, out error));
|
||||
Assert.Contains("inconnue", error);
|
||||
Assert.False(Keyring.VerifyRoot(keyring with { Signature = null }, roots, out _));
|
||||
|
||||
// Structure : identifiant ne correspondant pas à la clé, autorisation inconnue.
|
||||
var errors = new List<string>();
|
||||
Assert.False(Keyring.Validate(keyring with
|
||||
{
|
||||
Keys = new() { SigningKit.Entry(admin, "Nicolas") with { KeyId = SigningKit.KeyId(intruder) }, SigningKit.Entry(intruder, "X", "admin") }
|
||||
}, errors));
|
||||
Assert.Contains(errors, e => e.Contains("ne correspond pas"));
|
||||
Assert.Contains(errors, e => e.Contains("autorisations"));
|
||||
|
||||
// Membres absents (« keys »: null, « scopes »: null…) : refusés sans exception.
|
||||
foreach (var json in new[]
|
||||
{
|
||||
"""{ "sequence": 1, "keys": null }""",
|
||||
"""{ "sequence": 1, "keys": [ null ] }""",
|
||||
"""{ "sequence": 1, "keys": [ { "keyId": "0123456789abcdef", "publicKey": null, "name": "X", "scopes": null } ] }"""
|
||||
})
|
||||
{
|
||||
var parsed = Keyring.Parse(Encoding.UTF8.GetBytes(json))!;
|
||||
errors.Clear();
|
||||
Assert.False(Keyring.Validate(parsed, errors));
|
||||
Assert.False(Keyring.VerifyRoot(parsed, roots, out _));
|
||||
}
|
||||
|
||||
// Nom trompeur (inversion du sens d'écriture, caractère invisible) : refusé.
|
||||
errors.Clear();
|
||||
Assert.False(Keyring.Validate(SigningKit.Keyring(root, 4, SigningKit.Entry(admin, "Nicolas \u202Eexe.cod")), errors));
|
||||
Assert.False(Keyring.IsDisplaySafe("Paul\u200B"));
|
||||
Assert.True(Keyring.IsDisplaySafe("Nicolas — PC fixe"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_manifest_with_missing_members_still_has_a_digest()
|
||||
{
|
||||
// Manifest incomplet ou malformé (membres null) : condensé calculé, jamais d'exception.
|
||||
var digest = Digest("""{ "gameId": "EZR", "version": "1.0", "files": [ null ], "archive": null }""");
|
||||
Assert.Equal(64, digest.Length);
|
||||
Assert.NotEqual(digest, Digest(SigningKit.ManifestJson()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Catalog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Tests.Admin;
|
||||
|
||||
namespace GameLauncher.Tests.Signing;
|
||||
|
||||
/// <summary>Vérification du contenu par le launcher des joueurs (« contentSigning »).</summary>
|
||||
public class ContentTrustTests
|
||||
{
|
||||
private static readonly Uri KeyringUrl = new("https://launcher.example/catalog/keyring.json");
|
||||
private const string CatalogUrl = "https://launcher.example/catalog/catalog.json";
|
||||
|
||||
/// <summary>Serveur simulé : trousseau servi (ou indisponible).</summary>
|
||||
private sealed class FakeServer
|
||||
{
|
||||
public byte[]? Keyring { get; set; }
|
||||
public int Requests { get; private set; }
|
||||
|
||||
/// <summary>Erreur levée à la place de la réponse (délai dépassé, bogue…).</summary>
|
||||
public Exception? Failure { get; set; }
|
||||
|
||||
public Task<byte[]> Download(Uri uri, long max, CancellationToken ct)
|
||||
{
|
||||
Requests++;
|
||||
if (Failure is not null) throw Failure;
|
||||
return Keyring is { } bytes ? Task.FromResult(bytes) : throw new HttpRequestException("hors ligne");
|
||||
}
|
||||
|
||||
public void Serve(KeyringDocument keyring) => Keyring = Encoding.UTF8.GetBytes(GameLauncher.Contracts.Signing.Keyring.Serialize(keyring));
|
||||
}
|
||||
|
||||
private static ContentTrustService Trust(FakeServer server, string data, ECDsa root, ContentSigningMode mode, long minSequence = 0,
|
||||
TimeProvider? time = null) =>
|
||||
new(server.Download, data, KeyringUrl, new[] { SigningKit.Root(root) }, mode, minSequence, time);
|
||||
|
||||
/// <summary>Horloge avancée à la main.</summary>
|
||||
private sealed class ManualClock : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
|
||||
private static byte[] Bytes(CatalogDocument c) => Encoding.UTF8.GetBytes(JsonSerializer.Serialize(c, ApiJson.Options));
|
||||
|
||||
private static GameManifest Manifest(string version, ECDsa? signer, DateTimeOffset? signedAt = null)
|
||||
{
|
||||
var manifest = JsonSerializer.Deserialize<GameManifest>(SigningKit.ManifestJson(version), JsonDefaults.Options)!;
|
||||
if (signer is not null)
|
||||
manifest.Signature = SigningKit.Sign(signer, ContentKinds.Manifest, "EZR", ContentSigning.ManifestDigest(manifest.ToSignedContent()), signedAt);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task In_require_mode_only_signed_and_newer_manifests_are_accepted()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(admin, "Nicolas")));
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await trust.VerifyManifestAsync(Manifest("1.2", admin, now), "EZR");
|
||||
|
||||
var unsigned = await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.2", null), "EZR"));
|
||||
Assert.Contains("non signé", unsigned.Message);
|
||||
|
||||
// Contenu modifié après signature (archive remplacée par un serveur compromis).
|
||||
var tampered = Manifest("1.2", admin, now);
|
||||
tampered.Archive.Sha256 = new string('d', 64);
|
||||
Assert.Contains("SIGNATURE INVALIDE", (await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(tampered, "EZR"))).Message);
|
||||
|
||||
// Manifest d'un autre jeu servi à la place de celui-ci.
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.2", admin, now), "AUTRE"));
|
||||
|
||||
// Retour en arrière : une version légitime mais signée plus tôt est refusée, même après redémarrage.
|
||||
var older = Manifest("1.1", admin, now.AddDays(-7));
|
||||
Assert.Contains("retour en arrière", (await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(older, "EZR"))).Message);
|
||||
var restarted = Trust(server, folder.Path, root, ContentSigningMode.Require);
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => restarted.VerifyManifestAsync(older, "EZR"));
|
||||
await restarted.VerifyManifestAsync(Manifest("1.3", admin, now.AddMinutes(5)), "EZR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task In_warn_mode_problems_are_logged_and_the_content_is_used()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
var server = new FakeServer(); // trousseau indisponible
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Warn);
|
||||
await trust.VerifyManifestAsync(Manifest("1.2", null), "EZR");
|
||||
|
||||
var strict = Trust(server, folder.Path, root, ContentSigningMode.Require);
|
||||
Assert.Contains("trousseau indisponible",
|
||||
(await Assert.ThrowsAsync<ContentSecurityException>(() => strict.VerifyManifestAsync(Manifest("1.2", null), "EZR"))).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_forged_or_older_keyring_never_replaces_the_one_already_seen()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var forgedRoot = SigningKit.NewKey();
|
||||
using var nicolas = SigningKit.NewKey();
|
||||
using var revoked = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
|
||||
// Trousseau n° 2 vu (la clé « revoked » en a été retirée).
|
||||
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas")));
|
||||
Assert.Equal(2, (await Trust(server, folder.Path, root, ContentSigningMode.Require).GetKeyringAsync())!.Sequence);
|
||||
|
||||
// Le serveur ressert l'ancien n° 1 (qui contenait la clé retirée) : refusé, le n° 2 reste utilisé.
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(revoked, "Ancien PC")));
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require);
|
||||
Assert.Equal(2, (await trust.GetKeyringAsync())!.Sequence);
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.2", revoked), "EZR"));
|
||||
|
||||
// Trousseau signé par une autre racine, ou même numéro au contenu différent : refusés.
|
||||
server.Serve(SigningKit.Keyring(forgedRoot, 9, SigningKit.Entry(revoked, "Pirate")));
|
||||
Assert.Equal(2, (await Trust(server, folder.Path, root, ContentSigningMode.Require).GetKeyringAsync())!.Sequence);
|
||||
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(revoked, "Pirate")));
|
||||
Assert.Equal("Nicolas", (await Trust(server, folder.Path, root, ContentSigningMode.Require).GetKeyringAsync())!.Keys[0].Name);
|
||||
|
||||
// Hors ligne : le dernier trousseau vu est utilisé.
|
||||
server.Keyring = null;
|
||||
var offline = Trust(server, folder.Path, root, ContentSigningMode.Require);
|
||||
await offline.VerifyManifestAsync(Manifest("1.2", nicolas), "EZR");
|
||||
|
||||
// Fichier d'état modifié à la main (clé ajoutée) : le trousseau en cache est rejeté.
|
||||
var state = File.ReadAllText(offline.StatePath).Replace("Nicolas", "Nicolas modifié");
|
||||
File.WriteAllText(offline.StatePath, state);
|
||||
Assert.Null(await Trust(server, folder.Path, root, ContentSigningMode.Require).GetKeyringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unsigned_catalog_is_ignored_in_require_mode()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var editor = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(editor, "Paul", SigningScopes.Catalog)));
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require);
|
||||
|
||||
var catalog = new CatalogDocument
|
||||
{
|
||||
Revision = 3,
|
||||
Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Signé" } }
|
||||
};
|
||||
var signed = catalog with { Signature = SigningKit.Sign(editor, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog)) };
|
||||
|
||||
var unsignedService = new CatalogService((_, _, _) => Task.FromResult(Bytes(catalog)), folder.Path, new Uri(CatalogUrl), trust);
|
||||
Assert.Null(await unsignedService.FetchAsync());
|
||||
Assert.Null(unsignedService.LoadCached()); // rien mis en cache
|
||||
|
||||
var signedService = new CatalogService((_, _, _) => Task.FromResult(Bytes(signed)), folder.Path, new Uri(CatalogUrl), trust);
|
||||
Assert.Equal("Signé", (await signedService.FetchAsync())!.Games["EZR"].Tagline);
|
||||
|
||||
// Lien ajouté après signature : refusé, le catalogue en cache est conservé.
|
||||
var tampered = signed with
|
||||
{
|
||||
Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Signé", WebsiteUrl = "https://launcher.example/piege" } }
|
||||
};
|
||||
Assert.Null(await new CatalogService((_, _, _) => Task.FromResult(Bytes(tampered)), folder.Path, new Uri(CatalogUrl), trust).FetchAsync());
|
||||
Assert.Equal("Signé", signedService.LoadCached()!.Games["EZR"].Tagline);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_catalog_with_case_colliding_games_is_refused_even_if_signed()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var editor = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(editor, "Paul", SigningScopes.Catalog)));
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require);
|
||||
|
||||
// « EZR » signé, « ezr » ajouté : le joueur pourrait retenir l'un ou l'autre selon sa lecture.
|
||||
var catalog = new CatalogDocument
|
||||
{
|
||||
Revision = 5,
|
||||
Games = new Dictionary<string, CatalogGame>(StringComparer.Ordinal)
|
||||
{
|
||||
["EZR"] = new() { Tagline = "Officiel" },
|
||||
["ezr"] = new() { Tagline = "Piège", WebsiteUrl = "https://launcher.example/piege" }
|
||||
}
|
||||
};
|
||||
var signed = catalog with { Signature = SigningKit.Sign(editor, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog)) };
|
||||
Assert.False(await trust.VerifyCatalogAsync(signed));
|
||||
Assert.Null(await new CatalogService((_, _, _) => Task.FromResult(Bytes(signed)), folder.Path, new Uri(CatalogUrl), trust).FetchAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_keyring_failure_never_escapes_and_warn_mode_keeps_working()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
var server = new FakeServer { Failure = new TimeoutException("délai dépassé") };
|
||||
var warn = Trust(server, folder.Path, root, ContentSigningMode.Warn);
|
||||
Assert.Null(await warn.GetKeyringAsync());
|
||||
await warn.VerifyManifestAsync(Manifest("1.2", null), "EZR"); // signalé, utilisé
|
||||
|
||||
server.Failure = new NullReferenceException("bogue");
|
||||
var strict = Trust(server, folder.Path, root, ContentSigningMode.Require);
|
||||
Assert.Contains("trousseau indisponible",
|
||||
(await Assert.ThrowsAsync<ContentSecurityException>(() => strict.VerifyManifestAsync(Manifest("1.2", null), "EZR"))).Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_keyring_older_than_the_embedded_minimum_is_refused_even_for_a_new_installation()
|
||||
{
|
||||
using var root = SigningKit.NewKey();
|
||||
using var revoked = SigningKit.NewKey();
|
||||
using var nicolas = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
// Ancien trousseau n° 1, signé par la racine, qui contenait encore une clé compromise.
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(revoked, "Ancien PC"), SigningKit.Entry(nicolas, "Nicolas")));
|
||||
|
||||
using (var fresh = new TempFolder())
|
||||
{
|
||||
var trust = Trust(server, fresh.Path, root, ContentSigningMode.Require, minSequence: 2);
|
||||
Assert.Null(await trust.GetKeyringAsync());
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.2", revoked), "EZR"));
|
||||
}
|
||||
|
||||
// Un cache plus ancien que le minimum (launcher mis à jour) n'est plus utilisé non plus.
|
||||
using var folder = new TempFolder();
|
||||
Assert.Equal(1, (await Trust(server, folder.Path, root, ContentSigningMode.Require).GetKeyringAsync())!.Sequence);
|
||||
server.Keyring = null;
|
||||
Assert.Null(await Trust(server, folder.Path, root, ContentSigningMode.Require, minSequence: 2).GetKeyringAsync());
|
||||
|
||||
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas")));
|
||||
var current = Trust(server, folder.Path, root, ContentSigningMode.Require, minSequence: 2);
|
||||
await current.VerifyManifestAsync(Manifest("1.2", nicolas), "EZR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_key_added_while_the_launcher_runs_is_found_by_downloading_the_keyring_again()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var nicolas = SigningKit.NewKey();
|
||||
using var paul = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas")));
|
||||
var clock = new ManualClock();
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
|
||||
await trust.VerifyManifestAsync(Manifest("1.2", nicolas), "EZR");
|
||||
Assert.Equal(1, server.Requests);
|
||||
|
||||
// Nouvelle clé publiée depuis : nouvel essai limité dans le temps (pas un téléchargement par contenu).
|
||||
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(paul, "Paul")));
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.3", paul), "EZR"));
|
||||
Assert.Equal(1, server.Requests);
|
||||
|
||||
clock.Now += TimeSpan.FromMinutes(6);
|
||||
await trust.VerifyManifestAsync(Manifest("1.3", paul), "EZR");
|
||||
Assert.Equal(2, server.Requests);
|
||||
// Clé inconnue de nouveau juste après (contenu signé par une clé étrangère) : pas de nouveau téléchargement.
|
||||
using var stranger = SigningKit.NewKey();
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.4", stranger), "EZR"));
|
||||
Assert.Equal(2, server.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_signature_dated_far_in_the_future_does_not_block_later_releases()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(admin, "Nicolas")));
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require);
|
||||
|
||||
// Horloge déréglée lors d'une signature : date retenue plafonnée à demain.
|
||||
await trust.VerifyManifestAsync(Manifest("1.2", admin, DateTimeOffset.UtcNow.AddYears(5)), "EZR");
|
||||
// Une version signée correctement dans deux jours reste acceptée (pas cinq ans de blocage).
|
||||
await trust.VerifyManifestAsync(Manifest("1.3", admin, DateTimeOffset.UtcNow.AddDays(2)), "EZR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_cached_catalog_is_verified_again_at_startup_in_require_mode()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var editor = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(editor, "Paul", SigningScopes.Catalog)));
|
||||
var catalog = new CatalogDocument { Revision = 2, Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Avant" } } };
|
||||
|
||||
// Catalogue en cache écrit en mode « warn » (non signé) : ignoré au démarrage en mode « require ».
|
||||
var warn = Trust(server, folder.Path, root, ContentSigningMode.Warn);
|
||||
await warn.GetKeyringAsync();
|
||||
Assert.NotNull(await new CatalogService((_, _, _) => Task.FromResult(Bytes(catalog)), folder.Path, new Uri(CatalogUrl), warn).FetchAsync());
|
||||
var offline = new FakeServer();
|
||||
var strictService = new CatalogService((_, _, _) => throw new HttpRequestException("hors ligne"), folder.Path, new Uri(CatalogUrl),
|
||||
Trust(offline, folder.Path, root, ContentSigningMode.Require));
|
||||
Assert.Null(strictService.LoadCached());
|
||||
Assert.Equal(0, offline.Requests); // vérification hors ligne, avec le trousseau en cache
|
||||
|
||||
// Signé : accepté ; modifié ensuite sur le disque : ignoré.
|
||||
var signed = catalog with { Signature = SigningKit.Sign(editor, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog)) };
|
||||
Assert.NotNull(await new CatalogService((_, _, _) => Task.FromResult(Bytes(signed)), folder.Path, new Uri(CatalogUrl), warn).FetchAsync());
|
||||
Assert.Equal("Avant", strictService.LoadCached()!.Games["EZR"].Tagline);
|
||||
File.WriteAllText(strictService.CachePath, File.ReadAllText(strictService.CachePath).Replace("Avant", "Modifié"));
|
||||
Assert.Null(strictService.LoadCached());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Launcher_json_declares_the_keyring_and_the_mode()
|
||||
{
|
||||
LauncherCatalog Parse(string extra) => LauncherCatalog.Parse($$"""
|
||||
{ "launcherName": "L", "publisher": "P", {{extra}}
|
||||
"games": [ { "gameId": "EZR", "gameName": "Exovival", "manifestUrl": "https://launcher.example/ezr/manifest.json" } ] }
|
||||
""");
|
||||
Assert.Equal(ContentSigningMode.Off, Parse("").ContentSigning);
|
||||
var warn = Parse("\"keyringUrl\": \"https://launcher.example/catalog/keyring.json\",");
|
||||
Assert.Equal(ContentSigningMode.Warn, warn.ContentSigning);
|
||||
Assert.Equal("https://launcher.example/catalog/keyring.json", warn.KeyringUrl);
|
||||
Assert.Equal(ContentSigningMode.Require,
|
||||
Parse("\"keyringUrl\": \"https://launcher.example/catalog/keyring.json\", \"contentSigning\": \"require\",").ContentSigning);
|
||||
var noKeyring = Parse("\"contentSigning\": \"require\",");
|
||||
Assert.Equal(ContentSigningMode.Require, noKeyring.ContentSigning); // jamais assoupli
|
||||
Assert.NotNull(noKeyring.ContentSigningError);
|
||||
var invalid = Parse("\"keyringUrl\": \"http://launcher.example/k.json\", \"contentSigning\": \"peut-être\",");
|
||||
Assert.Null(invalid.KeyringUrl);
|
||||
Assert.NotNull(invalid.KeyringUrlError);
|
||||
Assert.Equal(ContentSigningMode.Warn, invalid.ContentSigning);
|
||||
|
||||
Assert.Equal(0, warn.KeyringMinSequence);
|
||||
Assert.Equal(3, Parse("\"keyringUrl\": \"https://launcher.example/catalog/keyring.json\", \"keyringMinSequence\": 3,").KeyringMinSequence);
|
||||
var badMin = Parse("\"keyringUrl\": \"https://launcher.example/catalog/keyring.json\", \"keyringMinSequence\": -1,");
|
||||
Assert.Equal(0, badMin.KeyringMinSequence);
|
||||
Assert.NotNull(badMin.ContentSigningError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Security.Cryptography;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Tests.Signing;
|
||||
|
||||
/// <summary>Clés et trousseaux de test (clé racine comprise, comme celle des mises à jour du launcher).</summary>
|
||||
public static class SigningKit
|
||||
{
|
||||
public static ECDsa NewKey() => ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
|
||||
public static string KeyId(ECDsa key) => AdminKeys.ComputeKeyId(key.ExportSubjectPublicKeyInfo());
|
||||
|
||||
/// <summary>Clé racine telle que le launcher la connaît (identifiant, SPKI).</summary>
|
||||
public static (string KeyId, byte[] PublicKey) Root(ECDsa root) => (KeyId(root), root.ExportSubjectPublicKeyInfo());
|
||||
|
||||
public static KeyringKey Entry(ECDsa key, string name, params string[] scopes) => new()
|
||||
{
|
||||
KeyId = KeyId(key),
|
||||
PublicKey = Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()),
|
||||
Name = name,
|
||||
Scopes = scopes.Length == 0 ? SigningScopes.All.ToList() : scopes.ToList()
|
||||
};
|
||||
|
||||
public static KeyringDocument Keyring(ECDsa root, long sequence, params KeyringKey[] keys) =>
|
||||
GameLauncher.Contracts.Signing.Keyring.Sign(new KeyringDocument
|
||||
{
|
||||
Sequence = sequence,
|
||||
IssuedAt = DateTimeOffset.UtcNow,
|
||||
Keys = keys.ToList()
|
||||
}, root);
|
||||
|
||||
public static ContentSignature Sign(ECDsa key, string kind, string id, string digest, DateTimeOffset? at = null)
|
||||
{
|
||||
var signedAt = ContentSigning.Truncate(at ?? DateTimeOffset.UtcNow);
|
||||
var value = AdminKeys.Sign(key, ContentSigning.Message(kind, id, signedAt, KeyId(key), digest));
|
||||
return new ContentSignature { KeyId = KeyId(key), SignedAt = signedAt, Value = Convert.ToBase64String(value) };
|
||||
}
|
||||
|
||||
/// <summary>Manifest d'exemple (JSON, comme produit par ManifestBuilder).</summary>
|
||||
public static string ManifestJson(string version = "1.2", string executableSha = "aa", string changelogTitle = "Version 1.2") => $$"""
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"gameId": "EZR",
|
||||
"version": "{{version}}",
|
||||
"releaseDate": "2026-10-31T18:00:00+01:00",
|
||||
"executable": "Exovival Zombie Reborn.exe",
|
||||
"archive": { "url": "EZR-{{version}}.zip", "size": 1234, "sha256": "{{new string('c', 64)}}" },
|
||||
"objectsBaseUrl": "objects/",
|
||||
"files": [
|
||||
{ "path": "Exovival Zombie Reborn.exe", "size": 1000, "sha256": "{{executableSha.PadRight(64, 'a')}}" },
|
||||
{ "path": "Data/level1.pak", "size": 234, "sha256": "{{new string('B', 64)}}" }
|
||||
],
|
||||
"changelog": [ { "version": "{{version}}", "title": "{{changelogTitle}}", "changes": [ "Nouveautés." ] } ]
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -3,6 +3,8 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Services;
|
||||
@@ -43,6 +45,7 @@ public static class Program
|
||||
case "keygen": KeyGen(options); return 0;
|
||||
case "publish": await PublishAsync(options); return 0;
|
||||
case "verify": return await VerifyAsync(options) ? 0 : 3;
|
||||
case "sign-keyring": return SignKeyring(options);
|
||||
default: return Usage();
|
||||
}
|
||||
}
|
||||
@@ -378,6 +381,122 @@ public static class Program
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ sign-keyring
|
||||
|
||||
/// <summary>
|
||||
/// Signe une demande de trousseau (exportée par la page « Signature » du menu d'administration) avec la clé
|
||||
/// RACINE (celle des mises à jour du launcher). Hors ligne : cette clé n'est jamais ouverte par le launcher ni
|
||||
/// envoyée au serveur ; le trousseau signé est ensuite publié depuis le menu d'administration.
|
||||
/// </summary>
|
||||
private static int SignKeyring(Options o)
|
||||
{
|
||||
var input = Path.GetFullPath(o.Require("--in"));
|
||||
var keyPath = Path.GetFullPath(o.Get("--key") ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".launcher-keys", PrivateKeyFileName));
|
||||
var roots = o.Get("--public-key") is { } publicKeys
|
||||
? UpdateSignature.ParsePublicKeys(File.ReadAllText(publicKeys)).Select(k => (k.KeyId, k.SubjectPublicKeyInfo)).ToList()
|
||||
: null;
|
||||
|
||||
var request = Keyring.Parse(File.ReadAllBytes(input)) ?? throw new InvalidDataException("Demande de trousseau vide.");
|
||||
var errors = new List<string>();
|
||||
if (!Keyring.Validate(request, errors))
|
||||
throw new InvalidDataException("Demande de trousseau invalide :" + Environment.NewLine + " " + string.Join(Environment.NewLine + " ", errors));
|
||||
// Nom par défaut numéroté : les trousseaux signés successifs restent côte à côte (comparaison au suivant).
|
||||
var output = Path.GetFullPath(o.Get("--out") ?? Path.Combine(Path.GetDirectoryName(input)!, $"keyring-{request.Sequence}.json"));
|
||||
|
||||
// Trousseau précédent (signé, vérifié) : la demande vient du serveur, elle n'est PAS digne de confiance en
|
||||
// elle-même. Les clés nouvelles ou aux autorisations élargies sont signalées pour être vérifiées une à une.
|
||||
var previousPath = o.Get("--previous") is { } explicitPrevious
|
||||
? Path.GetFullPath(explicitPrevious)
|
||||
: FindPreviousKeyring(Path.GetDirectoryName(input)!, request.Sequence, roots);
|
||||
KeyringDocument? previous = null;
|
||||
if (previousPath is not null)
|
||||
{
|
||||
previous = Keyring.Parse(File.ReadAllBytes(previousPath)) ?? throw new InvalidDataException($"{previousPath} est vide.");
|
||||
if (previous.Signature is null)
|
||||
throw new InvalidDataException($"{Path.GetFileName(previousPath)} n'est pas un trousseau signé.");
|
||||
if (roots is not null && !Keyring.VerifyRoot(previous, roots, out var previousError))
|
||||
throw new CryptographicException($"{Path.GetFileName(previousPath)} : {previousError}");
|
||||
if (request.Sequence <= previous.Sequence)
|
||||
throw new InvalidDataException($"Demande n° {request.Sequence} : le trousseau précédent porte déjà le n° {previous.Sequence}.");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Trousseau n° {request.Sequence} du {request.IssuedAt.ToLocalTime():dd/MM/yyyy HH:mm} — {request.Keys.Count} clé(s) :");
|
||||
Console.WriteLine(previous is null
|
||||
? " (aucun trousseau signé précédent trouvé à côté de la demande : TOUTES les clés sont à vérifier)"
|
||||
: $" (comparé au trousseau n° {previous.Sequence} : {Path.GetFileName(previousPath)})");
|
||||
var previousKeys = previous?.Keys.ToDictionary(k => k.KeyId, StringComparer.Ordinal) ?? new Dictionary<string, KeyringKey>();
|
||||
var toCheck = 0;
|
||||
foreach (var key in request.Keys.OrderBy(k => k.Name, StringComparer.CurrentCultureIgnoreCase))
|
||||
{
|
||||
var fingerprint = AdminKeys.ComputeFingerprint(Convert.FromBase64String(key.PublicKey));
|
||||
string? flag = null;
|
||||
if (!previousKeys.TryGetValue(key.KeyId, out var old) || old.PublicKey != key.PublicKey) flag = "NOUVELLE CLÉ";
|
||||
else if (key.Scopes.Except(old.Scopes, StringComparer.Ordinal).Any()) flag = "AUTORISATIONS ÉLARGIES";
|
||||
if (flag is not null) toCheck++;
|
||||
Console.WriteLine($" • {key.Name}");
|
||||
var line = $" empreinte {fingerprint} · clé {key.KeyId} · {string.Join(", ", key.Scopes.Select(SigningScopes.DisplayName))}";
|
||||
if (flag is null) Console.WriteLine(line);
|
||||
else Warn($"{line} ← {flag}");
|
||||
}
|
||||
foreach (var removed in previousKeys.Values.Where(k => request.Keys.All(r => r.KeyId != k.KeyId)))
|
||||
Console.WriteLine($" − retirée : {removed.Name} (clé {removed.KeyId})");
|
||||
if (request.Keys.Count == 0)
|
||||
Warn("Aucune clé : les launchers en mode « require » n'accepteront plus aucun contenu.");
|
||||
if (toCheck > 0)
|
||||
Warn($"{toCheck} clé(s) à vérifier HORS DU SERVEUR : demandez à chaque personne de lire l'empreinte de son appareil " +
|
||||
"(menu d'administration › Mes appareils) de vive voix ou par téléphone, et comparez. Une clé que vous ne " +
|
||||
"reconnaissez pas peut venir d'un serveur compromis : abandonnez.");
|
||||
|
||||
if (!o.Has("--yes"))
|
||||
{
|
||||
Console.Write("Signer ce trousseau avec la clé racine ? (o/N) ");
|
||||
var answer = Console.ReadLine()?.Trim().ToLowerInvariant();
|
||||
if (answer is not ("o" or "oui"))
|
||||
{
|
||||
Warn("Abandon : rien n'a été signé.");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
using var rootKey = LoadPrivateKey(keyPath);
|
||||
var rootKeyId = AdminKeys.ComputeKeyId(rootKey.ExportSubjectPublicKeyInfo());
|
||||
if (roots is not null && roots.All(k => !string.Equals(k.KeyId, rootKeyId, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new CryptographicException(
|
||||
$"La clé {rootKeyId} n'est pas dans {Path.GetFileName(o.Get("--public-key"))} : les launchers refuseraient ce trousseau.");
|
||||
|
||||
if (File.Exists(output) && !o.Has("--force"))
|
||||
throw new IOException($"{output} existe déjà (--force pour le remplacer).");
|
||||
File.WriteAllText(output, Keyring.Serialize(Keyring.Sign(request, rootKey)));
|
||||
Success($"Trousseau n° {request.Sequence} signé par la clé racine {rootKeyId} : {output}");
|
||||
Console.WriteLine(" → publiez-le : menu d'administration › Signature › « Publier le trousseau signé… ».");
|
||||
Console.WriteLine(" → gardez ce fichier dans ce dossier : il servira de référence pour le prochain trousseau.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Trousseau signé le plus récent (keyring-N.json, N inférieur à la demande) du dossier, sinon null.</summary>
|
||||
internal static string? FindPreviousKeyring(string directory, long requestSequence, IReadOnlyList<(string KeyId, byte[] PublicKey)>? roots)
|
||||
{
|
||||
var candidates = new List<(long Sequence, string Path)>();
|
||||
foreach (var path in Directory.EnumerateFiles(directory, "keyring-*.json"))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(path)["keyring-".Length..];
|
||||
if (!long.TryParse(name, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var number)) continue;
|
||||
try
|
||||
{
|
||||
if (new FileInfo(path).Length > Keyring.MaxBytes || Keyring.Parse(File.ReadAllBytes(path)) is not { Signature: not null } keyring) continue;
|
||||
if (keyring.Sequence >= requestSequence || keyring.Sequence != number) continue;
|
||||
if (roots is not null && !Keyring.VerifyRoot(keyring, roots, out _)) continue;
|
||||
candidates.Add((keyring.Sequence, path));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Text.Json.JsonException)
|
||||
{
|
||||
// fichier illisible : ignoré
|
||||
}
|
||||
}
|
||||
return candidates.OrderByDescending(c => c.Sequence).Select(c => c.Path).FirstOrDefault();
|
||||
}
|
||||
|
||||
private static int Usage()
|
||||
{
|
||||
Console.WriteLine("""
|
||||
@@ -395,6 +514,13 @@ public static class Program
|
||||
verify --manifest <launcher-update.json | https://…> --public-key <update-signing.pub> [--exe <fichier>]
|
||||
Vérifie la signature (et l'exécutable).
|
||||
|
||||
sign-keyring --in <keyring-request.json> [--out <keyring-N.json>] [--key <launcher-signing.key>]
|
||||
[--previous <keyring-M.json>] [--public-key <update-signing.pub>] [--yes] [--force]
|
||||
Signe avec la clé racine le trousseau exporté par la page « Signature » du menu
|
||||
d'administration (clés autorisées à signer versions et catalogue). Compare la demande
|
||||
au trousseau signé précédent (par défaut : keyring-M.json le plus récent du dossier)
|
||||
et signale les clés nouvelles ou aux autorisations élargies.
|
||||
|
||||
Phrase de passe : saisie au clavier ou variable d'environnement LAUNCHER_KEY_PASSPHRASE.
|
||||
""");
|
||||
return 1;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user