Compare commits

...
Author SHA1 Message Date
nicolas 2e4b779be5 Déploiement : dossiers objects/00 à ff créés sur le VPS avec le bit setgid 2026-11-01 13:36:14 +01:00
nicolas 10558a6e5f Documentation : création des dossiers objects sous systemd 2026-11-01 12:52:36 +01:00
nicolas 54941a1009 Tests : droits des dossiers objects, test du bandeau d'envoi interrompu stabilisé
tests/GameLauncher.Tests/Publishing/PublishingTests.cs : nouveau test sur les droits des dossiers objects/ et objects/xx.
tests/GameLauncher.Tests/Admin/PublishPanelTests.cs : test du bandeau rendu stable (le refus 429 tombe sur un petit envoi).
tests/GameLauncher.Tests/Server/ChangelogModuleTests.cs : le faux dossier du jeu ne redemande pas le setgid s’il l’a déjà.
2026-11-01 12:52:25 +01:00
nicolas bd8b76015f Serveur : dossiers objects créés sans demander le bit setgid (RestrictSUIDSGID de systemd) 2026-11-01 12:51:55 +01:00
nicolas 0d3d76a68e Déploiement : zone nginx publication_par_ip, guide et architecture
server/deploy/nginx-publications.conf
server/deploy/LISEZMOI.md
docs/ADMIN-ARCHITECTURE.md
2026-11-01 12:27:08 +01:00
nicolas 8862047e36 Tests : limite des envois, délais d'attente, bandeau d'envoi interrompu:
tests/GameLauncher.Tests/Infrastructure/TestApi.cs : valeur par défaut de la nouvelle limite.
tests/GameLauncher.Tests/Publishing/PublishingTests.cs : deux tests.
Le premier vérifie qu’un envoi n’est pas freiné par la limite générale, et que les refus indiquent le délai d’attente.
Le second vérifie le calcul des attentes.
tests/GameLauncher.Tests/Admin/PublishPanelTests.cs : un test vérifie que le bandeau n’apparaît pas pendant l’envoi, que la pause est affichée, qu’on peut interrompre pendant la pause puis abandonner.
2026-11-01 12:26:52 +01:00
nicolas 25c87d672e Publication : bandeau « interrompu » masqué pendant l'envoi 2026-11-01 12:25:58 +01:00
nicolas d687414872 Publication : pauses courtes après un refus (Retry-After), pause affichée sur la page 2026-11-01 12:25:42 +01:00
nicolas fb71661f17 Serveur : limite de requêtes propre aux envois de publication, Retry-After sur les refus 429 2026-11-01 12:25:04 +01:00
nicolas 19ccd3bf9a Version 1.8 du jeu 2026-11-01 11:59:29 +01:00
nicolas 370443f3d4 Commandes : notification des boutons renvoyée sur le fil de l'interface, tests 2026-11-01 11:59:13 +01:00
nicolas 213cb9a533 Publication : avancement et ouverture rappelés sur le fil de l'interface 2026-11-01 11:58:39 +01:00
nicolas dc740e0452 Documentation : phase 5 (publication d'une version) 2026-10-31 20:17:22 +01:00
nicolas 72e979afd6 Déploiement : routes d'envoi dans nginx, guide du VPS 2026-10-31 20:17:02 +01:00
nicolas 1d5f85a77c Launcher : page « Publier une version » (vues, icônes) et enregistrement du module 2026-10-31 20:16:38 +01:00
nicolas 629fb600e9 Administration : module « Publication » (contrôle du dossier, envoi avec reprise, opération longue) 2026-10-31 20:16:04 +01:00
nicolas 9455277c25 Client API : requêtes d'envoi signées, délai par requête 2026-10-31 20:14:47 +01:00
nicolas f1032df74a Serveur : module Publishing (envoi par morceaux, reprise, mise en ligne, audit) 2026-10-31 20:14:27 +01:00
nicolas a341bc641a Serveur : fichiers publics des jeux communs aux notes de version et à la publication 2026-10-31 20:13:55 +01:00
nicolas 9b544cbc03 Contrats : publication d'une version (routes, échanges, contrôle des manifests) 2026-10-31 20:12:46 +01:00
nicolas 001ab82fda VErsion 1.7 du jeu 2026-10-31 17:51:29 +01:00
nicolas 67f4c02efc Documentation : phase 4 (notes de version) 2026-10-31 11:55:23 +01:00
nicolas 8808fe2615 Déploiement : notes de version modifiables sur le VPS (complément systemd, guide) 2026-10-31 11:55:09 +01:00
nicolas bd5b9d46ca Launcher : page « Notes de version » (vues) et enregistrement du module 2026-10-31 11:54:45 +01:00
nicolas 688b726e24 Administration : module « Notes de version » (éditeur, aperçu, enregistrement) 2026-10-31 11:54:21 +01:00
nicolas 929b27dbeb Client API : lecture et modification des notes de version 2026-10-31 11:53:48 +01:00
nicolas 090d8c64ec Serveur : module Changelog (notes d'une version dans le manifest publié, empreinte, audit) 2026-10-31 11:53:28 +01:00
nicolas 93f3ba630c Notes de version : modèle partagé dans Contracts, historique en ligne pour ManifestBuilder (--remote-manifest) 2026-10-31 11:52:25 +01:00
58 changed files with 6112 additions and 150 deletions

No files matched your search

+2
View File
@@ -41,9 +41,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD
server\deploy\LISEZMOI.md = server\deploy\LISEZMOI.md
server\deploy\installer-api.sh = server\deploy\installer-api.sh
server\deploy\launcher-api-catalog.conf = server\deploy\launcher-api-catalog.conf
server\deploy\launcher-api-games.conf = server\deploy\launcher-api-games.conf
server\deploy\launcher-api.service = server\deploy\launcher-api.service
server\deploy\nginx-api.conf = server\deploy\nginx-api.conf
server\deploy\nginx-catalog.conf = server\deploy\nginx-catalog.conf
server\deploy\nginx-publications.conf = server\deploy\nginx-publications.conf
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{266A4FF5-5752-46BA-803C-09E351720DB2}"
+37 -5
View File
@@ -35,10 +35,10 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés (voir §8) |
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; module « Catalogue des jeux » (`Catalog/`, page « Jeux ») (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `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`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
@@ -50,7 +50,7 @@ src/GameLauncher/
├─ Assets/ background.png (thème par défaut), Themes/exovival-background.jpg, launcher.ico
├─ Themes/ Default.xaml et Exovival.xaml (thèmes), Icons.xaml (icônes), Controls.xaml (styles),
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
│ dont la page « Jeux »)
│ dont les pages « Jeux », « Notes de version » et « Publier une version »)
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
@@ -72,12 +72,25 @@ dotnet run --project tools\ManifestBuilder -c Release -- ^
--exe MonJeu.exe ^
--game-id MonJeu ^
--notes notes.txt ^
--remote-manifest https://launcher.example/monjeu/manifest.json ^
--exclude "*.pdb;*.log"
```
L'historique des notes est conservé d'une version à l'autre (30 versions) et affiché dans le
launcher (panneau « Notes de version », bouton **Agrandir** pour les lire en grand).
`--remote-manifest` (recommandé, déjà dans `tools\publish-example.bat`) : l'historique est repris du
manifest **en ligne**, et non du `manifest.json` local. Les notes corrigées depuis le menu
d'administration (page « Notes de version », §8) ne sont donc pas écrasées à la publication
suivante. Pas de manifest en ligne (première publication) : historique local. Serveur injoignable :
la publication s'arrête (rien n'est perdu ; réessayez, ou retirez l'option). Envoyez ensuite les
fichiers sans attendre. Republier une version sans `--notes` garde ses notes existantes.
**Mise en ligne** : depuis le menu d'administration, page **Publier une version** (§8) : choisissez le
`manifest.json` du dossier `--output`, puis « Publier ». Seuls les fichiers absents du serveur sont
envoyés, l'envoi reprend après une coupure, et le manifest est mis en ligne en dernier. WinSCP reste
possible (objects/ et l'archive d'abord, `manifest.json` en dernier), mais pas les deux en même temps.
### Format de `notes.txt`
Texte simple, à écrire comme une note de mise à jour (exemple complet : `tools/notes.txt`) :
@@ -474,8 +487,9 @@ launcher distribué : c'est l'identifiant de produit vérifié lors des mises à
Chantier en cours, découpé en phases : architecture, sécurité et planning dans
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils.
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). Les autres pages de gestion
arrivent dans les phases suivantes.
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**. Les pages « Administrateurs »
et « Journal d'audit » arrivent à la phase 6.
* **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é,
@@ -505,6 +519,24 @@ arrivent dans les phases suivantes.
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.
* **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),
boutons ↑ ↓ pour l'ordre, **Aperçu** avec la présentation exacte du launcher. Mêmes limites que le
launcher, vérifiées avant l'envoi. « Enregistrer » ne change que les notes (fichiers, archive,
version et exécutable restent identiques) ; si le manifest a changé entre-temps (nouvelle version
publiée), rien n'est écrasé et la page propose de recharger. Une nouvelle version et ses notes se
publient toujours avec ManifestBuilder (§3, `--remote-manifest`). Installation sur le VPS :
`server/deploy/LISEZMOI.md`, étape 3 ter.
* **Page « Publier une version »** (permission `releases.publish` : propriétaires et administrateurs) :
choix du `manifest.json` produit par le .bat (ManifestBuilder), contrôle du dossier (mêmes règles
que le serveur : chemins sûrs, archive et fichiers présents), version en ligne rappelée (avertissement
si même numéro ou version inférieure), puis envoi : manifest, fichiers **absents du serveur**
seulement (petits fichiers par lots, gros par morceaux), archive par morceaux de 4 Mo, chaque fichier
vérifié (SHA-256) des deux côtés, et mise en ligne avec le manifest **en dernier** (historique des
notes du serveur conservé). « Interrompre » garde ce qui a été envoyé : « Publier » reprend. Pas de
verrouillage pour inactivité pendant l'envoi. Installation sur le VPS : `server/deploy/LISEZMOI.md`,
étape 3 quater.
* **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 :
+91 -24
View File
@@ -68,7 +68,7 @@ Conséquences :
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques |
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
| `server/GameLauncher.Server` | net8.0 (ASP.NET Core) | **Nouveau.** Launcher API : modules serveur, authentification, administrateurs, audit, outil en ligne de commande |
| `tools/AdminKeyTool` | net8.0 | **Nouveau.** Crée une clé d'administrateur (fichier chiffré) et teste une connexion à l'API |
@@ -381,33 +381,100 @@ champ), pastille « modifié », contrôle complet avant l'envoi (mêmes règles
suspendue pendant l'envoi, conflit de révision signalé avec rechargement proposé, bandeau si la
publication n'est pas configurée.
## 10. Notes de version (phase 4)
## 10. Notes de version (phase 4 ✅)
* Éditeur graphique dans le menu admin : arborescence version › sections › éléments, édition des
champs du format enrichi, **aperçu en direct avec les mêmes modèles que le launcher**
(`Themes/Changelog.xaml`) et les mêmes bornes (`ChangelogLimits`).
* Éditeur graphique dans le menu admin (page « Notes de version », module `ChangelogAdminModule`,
permission `changelog.edit`) : liste des versions › en-tête (titre, sous-titre, introduction) ›
sections › éléments (modification ou commentaire, étiquette, sous-points, note), ordre ↑ ↓,
**aperçu en direct avec les mêmes modèles que le launcher** (`Themes/Changelog.xaml`) et les mêmes
bornes (`ChangelogLimits`). Une version à l'ancien format (liste à plat) est présentée en section(s)
sans titre et enregistrée au format enrichi.
* On édite **les versions existantes** uniquement ; la création d'une version reste au `.bat`.
* Enregistrement : le launcher envoie le `manifest.json` modifié avec l'empreinte de la version
qu'il a lue (concurrence optimiste) ; le serveur vérifie que **seules les notes ont changé**
(fichiers, archive, version, exécutable identiques), valide, sauvegarde l'ancien manifest puis
remplace atomiquement.
* **Cohérence avec ManifestBuilder** : aujourd'hui il reprend l'historique des notes depuis le
`manifest.json` *local*, ce qui écraserait les corrections faites en ligne. ManifestBuilder
recevra une option `--remote-manifest <url>` (ajoutée au `.bat`) : l'historique est repris du
serveur, seule la nouvelle version vient de `notes.txt`.
* **Modèle partagé** : `ChangelogEntry`/`ChangelogSection`/`ChangelogItem` et `ChangelogLimits` sont
dans `Contracts` (`GameLauncher.Contracts.Changelog`), utilisés par le launcher, ManifestBuilder, le
menu d'administration et le serveur (qui ne référence pas `Core`). `ChangelogLimits.Check` signale
les dépassements sans rien tronquer ; `Normalize` tronque (texte + « … » dans la longueur maximale).
* **API** :
* `GET v1/games/{gameId}/changelog` (administrateur connecté) : notes du manifest publié (liste à
plat omise quand les sections existent) et **empreinte SHA-256** du manifest lu ;
* `PUT v1/games/{gameId}/changelog/{version}` (`changelog.edit` + requête signée) : notes d'**une**
version (60 Ko au plus, sous la limite de 64 Ko des requêtes) + empreinte de base. Identifiant et
version : caractères sûrs uniquement (jamais utilisés dans un chemin).
* **Serveur** (`Storage/ChangelogStore`) : manifest configuré par jeu (`LauncherApi:Games:<id>:ManifestFile`,
chemin absolu, dossier existant, séparé des données privées ; `gameId` du manifest vérifié).
Sous verrou : empreinte comparée (409 `revision_conflict` si le manifest a changé : nouvelle version
publiée, autre administrateur), contrôle des bornes (400), entrée de la version remplacée dans
l'arbre JSON (numéro, date et champs inconnus conservés, quelle que soit la casse ; liste à plat
régénérée pour les anciens launchers), budget de tout l'historique vérifié, copie de l'ancien
manifest dans les données privées (20 dernières), écriture d'un fichier temporaire caché du même
dossier avec les droits du manifest existant, **empreinte revérifiée juste avant** le renommage
atomique. Refus (503) si le manifest est un lien symbolique, ou si le dossier n'a pas le bit setgid
alors que nginx lit le manifest par son groupe. Audit `changelog.edit`.
* **Droits sur le VPS** : le service doit pouvoir écrire dans le dossier du jeu (complément systemd
`ReadWritePaths`, `server/deploy/LISEZMOI.md` étape 3 ter). C'est un compromis : un service compromis
pourrait alors modifier les fichiers du jeu servis aux joueurs (le manifest n'est pas encore signé,
voir phase 7). L'autorisation est limitée au seul dossier du jeu ; sans `LauncherApi:Games`, rien
n'est modifiable et le dossier reste en lecture seule pour le service.
* **Cohérence avec ManifestBuilder** : option `--remote-manifest <url>` (ajoutée à
`tools\publish-example.bat`) : l'historique est repris du manifest **en ligne**, seule la nouvelle
version vient de `notes.txt`. 404 = première publication (historique local) ; toute autre erreur
arrête la publication. Republier une version sans `--notes` garde ses notes existantes.
## 11. Publication d'une version (phase 5)
## 11. Publication d'une version (phase 5 ✅)
Depuis le menu admin › « Publier une version » : choix du dossier produit par le `.bat`, contrôle
local (manifest valide, archive et objets présents avec les bonnes empreintes), puis envoi :
Depuis le menu admin › « Publier une version » (module `PublishAdminModule`, permission
`releases.publish`) : choix du `manifest.json` produit par le `.bat`, contrôle local (manifest valide,
archive et objets présents avec la bonne taille), puis envoi :
1. `objects/` : seuls les objets **absents du serveur** (le serveur répond à une liste d'empreintes) ;
2. l'archive ZIP, **par morceaux** (8 Mo, reprise possible, chaque morceau et le fichier final
vérifiés par SHA-256) ;
3. `manifest.json` **en dernier**, remplacé atomiquement (même règle qu'avec WinSCP).
1. le manifest (par morceaux), contrôlé par le serveur ; il répond par la liste des objets **absents**
de son dossier `objects/` ;
2. ces objets seulement : petits fichiers **par lots** (une requête pour des centaines de fichiers),
gros fichiers par morceaux ;
3. l'archive ZIP **par morceaux** de 4 Mo (reprise à la position reçue) ;
4. la mise en ligne : `manifest.json` **en dernier**, remplacé atomiquement (même règle qu'avec WinSCP).
Le serveur vérifie que chaque fichier référencé existe avec la bonne taille et la bonne empreinte
avant d'accepter le manifest. L'historique des notes du serveur est conservé.
**Contrôles communs** (`Contracts/Publishing/ManifestCheck`, launcher et serveur) : identifiant de jeu
et version attendus ; archive = simple nom `.zip` à côté du manifest ; objets dans `objects/` uniquement ;
chemins relatifs sûrs, sans doublon ; exécutable présent ; 10 000 fichiers, 128 Go d'archive au plus.
**Envoi** (`Storage/PublicationStore`, module `Publishing`) : toutes les requêtes sont **signées** (y
compris chaque morceau : la signature couvre l'empreinte du corps). Seules les routes d'envoi acceptent
8 Mo (limite propre à la route, côté Kestrel et nginx) ; le reste de l'API garde 64 Ko. Dépôt dans un
dossier **caché** du dossier du jeu (`.publish-<id>`, 0700, refusé par nginx) : la mise en ligne ne
consiste qu'en renommages sur le même disque. Chaque morceau doit suivre exactement ce qui a été reçu
(409 `offset_mismatch` sinon) ; l'empreinte SHA-256 est calculée au fil des morceaux (recalculée si le
service a redémarré) et vérifiée avant d'accepter le fichier. Espace disque vérifié (507). Une
publication par jeu ; celle d'un autre administrateur n'est remplaçable qu'après 30 min sans envoi ;
abandon au bout de 48 h. Limite de requêtes : les routes d'envoi de morceaux ont leur propre limite
(`Limits:PublicationUploadsPerMinute`, 1500/min par IP, politique `publication-upload`, à la place de la
limite générale) ; côté nginx, zone `publication_par_ip` (25/s). Chaque refus 429 du serveur annonce
son délai (`Retry-After`).
**Mise en ligne** : tout est revérifié, puis contrôles **avant tout déplacement** (liens symboliques,
droits, dossiers `objects/xx`) ; objets, archive (l'ancienne, de même nom, gardée de côté et remise en
place si l'écriture du manifest échoue), puis manifest. Refus si les fichiers en ligne ont changé
pendant l'envoi (empreinte du manifest **hors notes** : une correction de notes reste permise).
Notes : celles de la version publiée, puis l'historique **du serveur** (corrections en ligne
conservées), dans les bornes du launcher. Résultat gardé 24 h : une réponse perdue n'empêche pas de
constater la mise en ligne. Audit : `release.start`, `release.publish`, `release.cancel`.
Dossiers `objects/xx` manquants : créés **sans demander de bit spécial** (systemd refuse au service toute
demande de setuid/setgid, `RestrictSUIDSGID=yes`) ; le groupe `www-data` vient du bit setgid de `objects/`,
et les droits sont ceux du dossier du jeu, mais sans setgid (le fixer sous le masque du service le retire) :
sans conséquence pour le launcher, qui y déplace des fichiers déjà du bon groupe, mais un fichier déposé
avec WinSCP y prendrait le groupe `deploy`. D'où l'étape 3 quater : `objects/` et ses 256 sous-dossiers
créés sur le VPS en `2750`.
**Launcher** (`Modules.Admin/Publishing/ReleaseUploader`) : les empreintes des fichiers sont vérifiées
pendant leur lecture (un fichier modifié depuis le `.bat` est refusé) ; coupures (réseau, délai,
429, serveur occupé, position décalée) : l'état est relu sur le serveur et l'envoi continue, après une
pause affichée sur la page (429 : délai `Retry-After` du serveur, sinon 1 s doublée à chaque refus,
30 s au plus) ; « Interrompre »
garde ce qui a été reçu, « Publier » reprend. La mise en ligne elle-même ne s'interrompt pas. Pendant
l'envoi, `IAdminContext.BeginLongOperation()` évite le verrouillage pour inactivité (verrouillage manuel,
de Windows et durée maximale de session toujours appliqués).
**VPS** : bloc nginx `server/deploy/nginx-publications.conf`, écriture dans le dossier du jeu
(`games.conf`, étape 3 ter), dossier `objects` existant avec le bit setgid (`LISEZMOI.md`, étape 3 quater).
## 12. Signature de bout en bout pour les joueurs (phase 7, recommandée)
@@ -432,8 +499,8 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
| **2** | Launcher : socle des modules (`GameLauncher.Modules`), module Administration (`GameLauncher.Modules.Admin`), entrée discrète (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), clé en fichier chiffré, connexion, « J'ai une invitation », tableau de bord | ✅ |
| **2b** | Clé dans la puce TPM 2.0 (non exportable, code PIN vérifié par la puce), au choix en plus du fichier chiffré | ✅ |
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | ✅ |
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | à faire |
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | à faire |
| **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 « Administrateurs » (invitations, approbation par empreinte, rôles, révocation, appareils) et « Journal d'audit » | à faire |
| **7** | Signature de bout en bout (trousseau signé par la clé racine, signatures détachées, vérification côté joueur) | recommandée |
@@ -26,7 +26,9 @@ public static class ApiModules
new Modules.Auth.AuthModule(),
new Modules.Admins.AdminsModule(),
new Modules.Audit.AuditModule(),
new Modules.Catalog.CatalogModule()
new Modules.Catalog.CatalogModule(),
new Modules.Changelog.ChangelogModule(),
new Modules.Publishing.PublishingModule()
};
}
@@ -35,4 +37,10 @@ public static class RateLimitPolicies
{
public const string Auth = "auth";
public const string Enrollment = "enrollment";
/// <summary>
/// Morceaux envoyés pendant une publication (manifest, objets, archive) : limite propre, plus haute,
/// à la place de la limite générale (un envoi rapide en enchaîne plusieurs par seconde).
/// </summary>
public const string PublicationUpload = "publication-upload";
}
@@ -41,6 +41,12 @@ public sealed class LauncherApiOptions
public CatalogSettings Catalog { 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.
/// </summary>
public Dictionary<string, GameSettings> Games { get; set; } = new();
/// <summary>Durée de validité d'un défi de connexion.</summary>
public int ChallengeLifetimeSeconds { get; set; } = 60;
@@ -70,6 +76,17 @@ public sealed class LauncherApiOptions
public string PublicUrl { get; set; } = "";
}
/// <summary>Un jeu publié sur ce serveur.</summary>
public sealed class GameSettings
{
/// <summary>
/// manifest.json publié du jeu (chemin absolu dans le dossier servi par nginx, ex.
/// « /srv/exovival/www/exovivalzombie/manifest.json »). Son dossier doit être autorisé en écriture
/// par systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
/// </summary>
public string ManifestFile { get; set; } = "";
}
public sealed class LimitSettings
{
/// <summary>Requêtes par minute et par adresse IP, toutes routes confondues.</summary>
@@ -78,6 +95,13 @@ public sealed class LauncherApiOptions
/// <summary>Défis et connexions par minute et par adresse IP.</summary>
public int AuthPerMinute { get; set; } = 20;
/// <summary>
/// Morceaux envoyés pendant une publication, par minute et par adresse IP (à la place de
/// <see cref="RequestsPerMinute"/> pour ces routes). 1500 morceaux de 4 Mo ≈ 100 Mo/s. Ces requêtes
/// exigent une session et le droit de publier : le corps n'est lu qu'après ces contrôles.
/// </summary>
public int PublicationUploadsPerMinute { get; set; } = 1500;
/// <summary>Enrôlements (codes d'invitation) par heure et par adresse IP.</summary>
public int EnrollmentsPerHour { get; set; } = 10;
@@ -108,6 +132,10 @@ public sealed class LauncherApiOptions
/// </summary>
public string CatalogImageHost { 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);
/// <summary>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
public void Validate(string contentRoot)
{
@@ -135,6 +163,7 @@ public sealed class LauncherApiOptions
TrustedProxyAddresses = proxies;
ValidateCatalog();
ValidateGames();
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
@@ -142,7 +171,8 @@ public sealed class LauncherApiOptions
Require(Sessions.MaxTotal is >= 1 and <= 100_000, "Sessions:MaxTotal");
Require(ChallengeLifetimeSeconds is >= 10 and <= 600, "ChallengeLifetimeSeconds (10 à 600)");
Require(InvitationLifetimeHours is >= 1 and <= 168, "InvitationLifetimeHours (1 à 168)");
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1, "Limits");
Require(Limits.RequestsPerMinute >= 1 && Limits.AuthPerMinute >= 1 && Limits.EnrollmentsPerHour >= 1 &&
Limits.PublicationUploadsPerMinute >= 1, "Limits");
Require(Limits.MaxActiveInvitations is >= 1 and <= 1000, "Limits:MaxActiveInvitations");
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
}
@@ -159,13 +189,7 @@ public sealed class LauncherApiOptions
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:Catalog:PublicFile : le dossier « {directory} » n'existe pas.");
// Ni le fichier public dans les données privées, ni les données privées sous le dossier public
// (servi par nginx) ; liens symboliques résolus.
var publicDirectory = RealPath(directory);
var dataDirectory = RealPath(DataDirectoryFullPath);
if (IsSameOrInside(publicDirectory, dataDirectory) || IsSameOrInside(dataDirectory, publicDirectory))
throw new InvalidOperationException(
$"{Section}:Catalog:PublicFile : le dossier public et le dossier des données privées doivent être séparés (aucun des deux dans l'autre).");
EnsureSeparatedFromData(directory, "Catalog:PublicFile");
CatalogPublicFileFullPath = full;
}
@@ -185,6 +209,43 @@ public sealed class LauncherApiOptions
CatalogImageHost = new Uri(CatalogPublicUrl).Host;
}
private void ValidateGames()
{
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (gameId, game) in Games)
{
var name = $"Games:{gameId}:ManifestFile";
if (!Contracts.Catalog.CatalogRules.IsValidGameId(gameId))
throw new InvalidOperationException($"{Section}:Games : identifiant de jeu invalide « {gameId} ».");
if (string.IsNullOrWhiteSpace(game?.ManifestFile)) continue; // jeu déclaré sans manifest : ignoré
var file = game.ManifestFile.Trim();
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{Section}:{name} doit être un chemin absolu vers le manifest .json du jeu.");
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:{name} : le dossier « {directory} » n'existe pas.");
EnsureSeparatedFromData(directory, name);
if (files.Values.Contains(full, StringComparer.Ordinal))
throw new InvalidOperationException($"{Section}:{name} : ce manifest est déjà utilisé par un autre jeu.");
files[gameId] = full;
}
GameManifestFiles = files;
}
/// <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).
/// </summary>
private void EnsureSeparatedFromData(string directory, string name)
{
var publicDirectory = RealPath(directory);
var dataDirectory = RealPath(DataDirectoryFullPath);
if (IsSameOrInside(publicDirectory, dataDirectory) || IsSameOrInside(dataDirectory, publicDirectory))
throw new InvalidOperationException(
$"{Section}:{name} : le dossier public et le dossier des données privées doivent être séparés (aucun des deux dans l'autre).");
}
/// <summary>Chemin absolu, dernier élément résolu s'il s'agit d'un lien symbolique.</summary>
private static string RealPath(string directory)
{
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Threading.RateLimiting;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
@@ -5,6 +6,7 @@ using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Hosting;
@@ -64,10 +66,20 @@ public static class ServerApp
builder.Services.AddRateLimiter(limiter =>
{
limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
limiter.OnRejected = (context, _) => new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
limiter.OnRejected = (context, _) =>
{
// Délai avant la prochaine fenêtre : le launcher reprend juste à temps au lieu d'attendre à l'aveugle.
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
return new ValueTask(ApiResults.WriteAsync(context.HttpContext, 429,
ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute avant de réessayer."));
};
limiter.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
IsPublicationUpload(context)
? RateLimitPartition.GetNoLimiter("publication") // limite propre (politique PublicationUpload)
: RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.RequestsPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.PublicationUpload, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.PublicationUploadsPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Auth, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.AuthPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Enrollment, context =>
@@ -112,6 +124,10 @@ public static class ServerApp
return app;
}
/// <summary>Route d'envoi de morceaux d'une publication (politique <see cref="RateLimitPolicies.PublicationUpload"/>).</summary>
private static bool IsPublicationUpload(HttpContext context) =>
context.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.PolicyName == RateLimitPolicies.PublicationUpload;
private static FixedWindowRateLimiterOptions Window(int permits, TimeSpan window) => new()
{
PermitLimit = permits,
@@ -0,0 +1,90 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Changelog;
/// <summary>
/// Notes de version des jeux (éditeur du menu d'administration) : modification des versions EXISTANTES
/// dans le manifest.json publié de chaque jeu (LauncherApi:Games). La création d'une version reste
/// l'affaire de ManifestBuilder (et de la publication).
/// • GET : tout administrateur connecté (le manifest est public de toute façon) ;
/// • PUT (une version) : permission changelog.edit, requête signée, mêmes bornes que le launcher,
/// empreinte du manifest attendue (409 si le manifest a changé entre-temps), audit.
/// </summary>
public sealed class ChangelogModule : IApiModule
{
public string Name => "Changelog";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<ChangelogStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.GameChangelogPattern, Get).RequireAdmin();
api.MapPut(ApiRoutes.GameChangelogEntryPattern, Save).RequirePermission(AdminPermissions.ChangelogEdit).RequireSignedRequest();
}
private static IResult Get(string gameId, ChangelogStore store, ILogger<ChangelogModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
try
{
return Results.Json(store.Read(gameId), ApiJson.Options);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(ex, "Lecture du manifest de {GameId}", gameId);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex is InvalidDataException
? $"Le serveur n'a pas pu lire le manifest de « {gameId} » : {ex.Message}"
: $"Le serveur n'a pas pu lire le manifest de « {gameId} » : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter).");
}
}
private static IResult Save(string gameId, string version, ChangelogUpdateRequest request, HttpContext context,
ChangelogStore store, AuditLog audit, ILogger<ChangelogModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
if (!ChangelogLimits.IsEditableVersion(version)) return ApiResults.BadRequest("Numéro de version invalide.");
if (request.Entry is null) return ApiResults.BadRequest("Notes de version absentes.");
if (string.IsNullOrWhiteSpace(request.BaseSha256) || request.BaseSha256.Length != 64)
return ApiResults.BadRequest("Empreinte du manifest absente ou invalide.");
if (!string.Equals(request.Entry.Version, version, StringComparison.Ordinal))
return ApiResults.BadRequest("La version des notes ne correspond pas à l'adresse.");
var errors = ChangelogLimits.Check(request.Entry);
if (errors.Count > 0)
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
ChangelogState state;
ChangelogEntry previous;
try
{
(state, previous) = store.Save(gameId, version, request.Entry, request.BaseSha256);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException)
{
logger.LogError(ex, "Modification des notes de {GameId} {Version}", gameId, version);
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, ex switch
{
InvalidDataException => $"Le serveur n'a pas pu lire le manifest : {ex.Message}",
ManifestWriteRefusedException => $"Modification refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).",
_ => "Le serveur n'a pas pu modifier le manifest : vérifiez le dossier du jeu et ses droits (server/deploy/LISEZMOI.md, étape 3 ter)."
});
}
var actor = context.Admin();
var after = state.Entries.FirstOrDefault(e => e.Version == version);
audit.Write("changelog.edit", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"{state.GameId} {version}",
$"{Count(previous)} → {Count(after)} élément(s), manifest {state.ManifestSha256[..12]}");
return Results.Json(state, ApiJson.Options);
}
private static int Count(ChangelogEntry? entry) => entry is null ? 0 : ChangelogLimits.DisplayedItems(entry);
}
@@ -0,0 +1,126 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Publishing;
/// <summary>
/// Publication d'une version depuis le menu d'administration (phase 5) : permission releases.publish,
/// TOUTES les requêtes signées (y compris chaque morceau de fichier), audit du début, de la mise en
/// ligne et de l'abandon. Les routes d'envoi acceptent des corps jusqu'à 8 Mo (les autres : 64 Ko).
/// </summary>
public sealed class PublishingModule : IApiModule
{
public string Name => "Publishing";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<PublicationStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
const string permission = AdminPermissions.ReleasesPublish;
api.MapPost(ApiRoutes.PublicationStartPattern, Start).RequirePermission(permission).RequireSignedRequest();
api.MapGet(ApiRoutes.PublicationPattern, GetState).RequirePermission(permission);
api.MapDelete(ApiRoutes.PublicationPattern, Cancel).RequirePermission(permission).RequireSignedRequest();
api.MapPut(ApiRoutes.PublicationManifestPattern, Manifest).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPut(ApiRoutes.PublicationArchivePattern, Archive).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPut(ApiRoutes.PublicationObjectPattern, UploadObject).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPost(ApiRoutes.PublicationObjectsPattern, UploadObjects).RequirePermission(permission).RequireSignedRequest()
.WithBodyLimit(PublishingLimits.MaxUploadRequestBytes).RequireRateLimiting(RateLimitPolicies.PublicationUpload);
api.MapPost(ApiRoutes.PublicationCommitPattern, Commit).RequirePermission(permission).RequireSignedRequest();
}
private static IResult Start(string gameId, PublicationStartRequest request, HttpContext context, PublicationStore store, AuditLog audit,
ILogger<PublishingModule> logger)
{
if (!CatalogRules.IsValidGameId(gameId)) return ApiResults.BadRequest("Identifiant de jeu invalide.");
var actor = context.Admin();
return Guard(logger, () =>
{
var (state, replaced) = store.Start(gameId, request, actor.Admin.Id, actor.Admin.DisplayName);
if (replaced is not null)
Audit(audit, context, "release.cancel", replaced.GameId, replaced.Version, $"remplacée par la version {state.Version}");
if (!state.Resumed)
Audit(audit, context, "release.start", state.GameId, state.Version, $"version en ligne : {Display(state.PublishedVersion)}");
return Results.Json(state, ApiJson.Options);
});
}
private static IResult GetState(string id, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.GetState(id, context.Admin().Admin.Id), ApiJson.Options));
private static IResult Cancel(string id, HttpContext context, PublicationStore store, AuditLog audit, ILogger<PublishingModule> logger) =>
Guard(logger, () =>
{
var record = store.Cancel(id, context.Admin().Admin.Id);
Audit(audit, context, "release.cancel", record.GameId, record.Version, "abandonnée par l'administrateur");
return Results.NoContent();
});
private static IResult Manifest(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.AppendManifest(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
private static IResult Archive(string id, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger) =>
Guard(logger, () => Results.Json(store.AppendArchive(id, context.Admin().Admin.Id, offset, context.CapturedBody()), ApiJson.Options));
private static IResult UploadObject(string id, string sha256, long offset, HttpContext context, PublicationStore store, ILogger<PublishingModule> logger)
{
if (!PublishingLimits.IsValidSha256(sha256)) return ApiResults.BadRequest("Empreinte d'objet invalide.");
return Guard(logger, () =>
Results.Json(store.AppendObject(id, context.Admin().Admin.Id, sha256, offset, context.CapturedBody()), ApiJson.Options));
}
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) =>
Guard(logger, () =>
{
var (result, record) = store.Commit(id, context.Admin().Admin.Id);
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]}");
return Results.Json(result, ApiJson.Options);
});
/// <summary>Erreurs de fichiers → 503 avec un message sans chemin (le détail va dans journalctl).</summary>
private static IResult Guard(ILogger logger, Func<IResult> action)
{
try
{
return action();
}
catch (ManifestWriteRefusedException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Publication refusée : {ex.Message} (server/deploy/LISEZMOI.md, étape 3 ter).");
}
catch (InvalidDataException ex)
{
return ApiResults.Error(503, ApiErrorCodes.NotConfigured, $"Le serveur n'a pas pu lire le manifest en ligne : {ex.Message}");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Publication d'une version");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire dans le dossier du jeu : vérifiez ses droits et l'espace disque (server/deploy/LISEZMOI.md, étape 3 ter).");
}
}
private static void Audit(AuditLog audit, HttpContext context, string action, string gameId, string version, string details)
{
var actor = context.Admin();
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"{gameId} {version}", details);
}
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -24,15 +24,43 @@ public static class SignedRequests
if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method) &&
!HttpMethods.IsOptions(context.Request.Method))
{
using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
var body = buffer.ToArray();
// Routes d'envoi de fichiers (publication) : limite propre, plus grande que celle des autres requêtes.
if (context.GetEndpoint()?.Metadata.GetMetadata<RequestBodyLimit>() is { } limit &&
context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() is { IsReadOnly: false } feature)
feature.MaxRequestBodySize = limit.MaxBytes;
byte[] body;
var maxBody = context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>()?.MaxRequestBodySize;
if (context.Request.ContentLength is { } announced && maxBody is { } max && announced > max)
throw new BadHttpRequestException("Requête trop volumineuse.", StatusCodes.Status413PayloadTooLarge);
if (context.Request.ContentLength is { } length and > 0 and <= int.MaxValue && maxBody is not null)
{
// Taille annoncée (bornée par Kestrel) : lecture directe, sans copie intermédiaire.
body = new byte[length];
await context.Request.Body.ReadExactlyAsync(body, context.RequestAborted);
if (await context.Request.Body.ReadAsync(new byte[1], context.RequestAborted) != 0)
throw new BadHttpRequestException("Corps plus long que annoncé.");
}
else
{
using var buffer = new MemoryStream();
await context.Request.Body.CopyToAsync(buffer, context.RequestAborted);
body = buffer.ToArray();
}
context.Items[BodyKey] = body;
context.Request.Body = new MemoryStream(body, writable: false);
}
await next();
});
/// <summary>Corps de la requête (déjà lu et vérifié par la signature) : routes qui reçoivent des données brutes.</summary>
public static byte[] CapturedBody(this HttpContext context) =>
context.Items.TryGetValue(BodyKey, out var captured) && captured is byte[] bytes ? bytes : Array.Empty<byte>();
/// <summary>La route accepte un corps plus grand que la limite générale (envoi de fichiers).</summary>
public static TBuilder WithBodyLimit<TBuilder>(this TBuilder builder, long maxBytes) where TBuilder : IEndpointConventionBuilder =>
builder.WithMetadata(new RequestBodyLimit(maxBytes));
/// <summary>La route exige une requête signée par la clé de la session (à placer après RequireAdmin/RequirePermission).</summary>
public static TBuilder RequireSignedRequest<TBuilder>(this TBuilder builder) where TBuilder : IEndpointConventionBuilder =>
builder.AddEndpointFilter(async (invocation, next) =>
@@ -94,3 +122,6 @@ public static class SignedRequests
return null;
}
}
/// <summary>Taille maximale du corps d'une route (métadonnée lue par <see cref="SignedRequests.UseRequestBodyCapture"/>).</summary>
public sealed record RequestBodyLimit(long MaxBytes);
@@ -0,0 +1,165 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Notes de version des jeux, dans le manifest.json PUBLIÉ de chaque jeu (LauncherApi:Games:&lt;id&gt;:ManifestFile).
/// • Seule l'entrée « changelog » de la version demandée est remplacée : le reste du manifest (fichiers,
/// archive, version, exécutable…) est recopié tel quel. Numéro et date de la version sont conservés.
/// • Contrôle des modifications concurrentes : empreinte SHA-256 du manifest lu par l'éditeur ; si le
/// fichier a changé (nouvelle version publiée, autre administrateur), la modification est refusée (409).
/// L'empreinte est vérifiée une seconde fois juste avant le remplacement.
/// • Écriture atomique (fichier temporaire caché du même dossier puis renommage), droits du manifest
/// existant conservés ; l'ancienne version est copiée dans les données privées (20 dernières).
/// </summary>
public sealed class ChangelogStore
{
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
public const long MaxManifestBytes = PublishedFiles.MaxManifestBytes;
/// <summary>Champs d'une entrée « changelog » modifiés par l'éditeur (jamais « version » ni « date »).</summary>
private static readonly string[] EditableFields = { "title", "subtitle", "introduction", "sections", "changes" };
private readonly IReadOnlyDictionary<string, string> _manifests;
private readonly string _dataDirectory;
private readonly object _writeLock = new();
public ChangelogStore(IOptions<LauncherApiOptions> options)
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath) { }
public ChangelogStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory)
{
_manifests = manifests;
_dataDirectory = dataDirectory;
}
/// <summary>Notes de version publiées du jeu.</summary>
public ChangelogState Read(string gameId)
{
var (path, key) = Locate(gameId);
var bytes = ReadManifest(path);
var root = Parse(bytes, key);
return State(key, root, bytes);
}
/// <summary>
/// Remplace les notes d'UNE version existante (déjà contrôlées par <see cref="ChangelogLimits.Check"/>).
/// </summary>
/// <returns>Le nouvel état et les notes remplacées (audit).</returns>
public (ChangelogState State, ChangelogEntry Previous) Save(string gameId, string version, ChangelogEntry entry, string baseSha256)
{
var (path, key) = Locate(gameId);
lock (_writeLock)
using (PublishedFiles.LockGame(_dataDirectory, key))
{
var bytes = ReadManifest(path);
if (!string.Equals(Sha256(bytes), baseSha256, StringComparison.OrdinalIgnoreCase))
throw Conflict();
var root = Parse(bytes, key);
var changelog = ChangelogArray(root)
?? throw ApiRejection.NotFound($"Le manifest de « {key} » ne contient aucune note de version.");
var index = -1;
for (var i = 0; i < changelog.Count; i++)
if (changelog[i] is JsonObject candidate && string.Equals(PublishedFiles.StringValue(candidate, "version"), version, StringComparison.Ordinal))
{
index = i;
break;
}
if (index < 0)
throw ApiRejection.NotFound($"La version {version} n'a pas de notes dans le manifest publié (seules les versions existantes se modifient).");
var old = (JsonObject)changelog[index]!;
var previous = Entries(new JsonArray(old.DeepClone())).FirstOrDefault() ?? new ChangelogEntry();
// Notes nettoyées et bornées (mêmes règles que le launcher) ; liste à plat régénérée pour les
// anciens launchers ; numéro et date de la version repris du manifest.
var clean = ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault()
?? throw ApiRejection.BadRequest("Les notes de version ne peuvent pas être vides.");
if (clean.Sections.Count > 0) clean.Changes = clean.ToFlatChanges();
// Entrée existante recopiée (numéro, date, propriétés inconnues de ce serveur conservés, quelle
// que soit la casse des noms), puis seuls les champs des notes sont remplacés.
var replacement = old.DeepClone().AsObject();
var edited = JsonSerializer.SerializeToNode(clean, PublishedFiles.ManifestJson)!.AsObject();
foreach (var name in EditableFields) PublishedFiles.SetProperty(replacement, name, edited[name]?.DeepClone());
changelog[index] = replacement;
// Budget de tout l'historique : au-delà, le launcher tronquerait les versions les plus anciennes.
var total = Entries(changelog).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems);
if (total > ChangelogLimits.MaxTotalItems)
throw ApiRejection.BadRequest(
$"Historique trop long : {total} éléments pour toutes les versions ({ChangelogLimits.MaxTotalItems} au plus).");
var newBytes = PublishedFiles.Serialize(root);
PublishedFiles.Backup(_dataDirectory, key, bytes);
PublishedFiles.WriteManifest(path, newBytes, baseSha256, Conflict);
return (State(key, root, newBytes), previous);
}
}
// ------------------------------------------------------------------ Lecture
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, "Modification des notes de version");
}
private static byte[] ReadManifest(string path) =>
PublishedFiles.ReadManifest(path) ?? throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
internal static JsonObject Parse(byte[] bytes, string gameId)
{
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(bytes), "Manifest");
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
var manifestGameId = PublishedFiles.StringValue(root, "gameId");
if (!string.IsNullOrEmpty(manifestGameId) && !string.Equals(manifestGameId, gameId, StringComparison.OrdinalIgnoreCase))
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
$"Le manifest configuré pour « {gameId} » est celui du jeu « {manifestGameId} » : vérifiez LauncherApi:Games sur le serveur.");
return root;
}
private static ChangelogState State(string gameId, JsonObject root, byte[] bytes)
{
var entries = ChangelogLimits.Normalize(Entries(ChangelogArray(root)));
// Liste à plat (anciens launchers) inutile à l'éditeur quand les sections existent : réponse allégée.
foreach (var entry in entries.Where(e => e.Sections.Count > 0)) entry.Changes = new List<string>();
return new ChangelogState
{
GameId = gameId,
ManifestVersion = PublishedFiles.StringValue(root, "version") ?? "",
ManifestSha256 = Sha256(bytes),
Entries = entries
};
}
/// <summary>Entrées du manifest ; une entrée illisible (date ou type invalide) rend le manifest illisible.</summary>
internal static List<ChangelogEntry?> Entries(JsonArray? changelog)
{
try
{
return changelog?.Deserialize<List<ChangelogEntry?>>(PublishedFiles.ManifestJson) ?? new List<ChangelogEntry?>();
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException or FormatException)
{
throw new InvalidDataException($"Notes de version illisibles dans le manifest : {ex.Message}", ex);
}
}
/// <summary>Tableau « changelog » du manifest (nom insensible à la casse, comme pour le launcher).</summary>
internal static JsonArray? ChangelogArray(JsonObject root) => PublishedFiles.Property(root, "changelog") as JsonArray;
public static string Sha256(byte[] bytes) => PublishedFiles.Sha256(bytes);
private static ApiRejection Conflict() => ApiRejection.Conflict(
"Le manifest a changé sur le serveur depuis son chargement (nouvelle version publiée ou autre administrateur) : rechargez les notes.",
ApiErrorCodes.RevisionConflict);
}
@@ -0,0 +1,838 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Publication d'une version depuis le menu d'administration (phase 5).
/// • Dépôt : dossier CACHÉ « .publish-&lt;id&gt; » dans le dossier du jeu (même disque : la mise en ligne
/// finale n'est qu'une suite de renommages ; nginx refuse les chemins commençant par un point), droits
/// 0700 ; description (version, plan) dans les données privées.
/// • Envoi : manifest, puis objets absents du serveur (par lots, ou par morceaux pour les gros), puis
/// archive par morceaux ; chaque fichier est reçu à la suite (position vérifiée : reprise possible
/// après une coupure) puis vérifié (SHA-256) avant d'être accepté.
/// • Mise en ligne (commit) : tout est revérifié AVANT de déplacer quoi que ce soit ; objets puis archive
/// installés, manifest en DERNIER (comme avec WinSCP), historique des notes du serveur conservé ; si
/// l'écriture du manifest échoue, l'archive remplacée est remise en place. Jamais si les fichiers en
/// ligne ont changé pendant l'envoi (autre publication, WinSCP).
/// • Une publication par jeu ; abandonnée au bout de 48 h sans envoi ; résultat conservé 24 h (une
/// réponse perdue n'empêche pas de constater que la version est en ligne).
/// </summary>
public sealed class PublicationStore
{
private const string RecordsFolder = "publications";
private const string StagingPrefix = ".publish-";
private const string DoneSuffix = ".done";
private const long FreeSpaceMargin = 256L * 1024 * 1024;
private static readonly TimeSpan DoneLifetime = TimeSpan.FromHours(24);
private static readonly TimeSpan CleanUpEvery = TimeSpan.FromMinutes(10);
private const UnixFileMode PrivateDirectory = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
private const UnixFileMode PrivateFile = UnixFileMode.UserRead | UnixFileMode.UserWrite;
private readonly IReadOnlyDictionary<string, string> _manifests;
private readonly string _dataDirectory;
private readonly string _recordsDirectory;
private readonly TimeProvider _time;
private readonly object _startLock = new();
private readonly ConcurrentDictionary<string, object> _locks = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, HashProgress> _hashes = new(StringComparer.Ordinal);
private DateTimeOffset _lastCleanUp = DateTimeOffset.MinValue;
public PublicationStore(IOptions<LauncherApiOptions> options, TimeProvider time)
: this(options.Value.GameManifestFiles, options.Value.DataDirectoryFullPath, time) { }
public PublicationStore(IReadOnlyDictionary<string, string> manifests, string dataDirectory, TimeProvider time)
{
_manifests = manifests;
_dataDirectory = dataDirectory;
_recordsDirectory = Path.Combine(dataDirectory, RecordsFolder);
_time = time;
}
// ================================================================== Début, état, abandon
/// <summary>
/// Début d'une publication, ou reprise si le même manifest était en cours d'envoi. Une publication
/// d'un autre administrateur, active depuis moins de 30 min, n'est pas remplacée (409).
/// </summary>
/// <returns>L'état, et la publication remplacée (audit) le cas échéant.</returns>
public (PublicationState State, PublicationRecord? Replaced) Start(string gameId, PublicationStartRequest request, Guid adminId, string adminName)
{
var (manifestPath, key) = Locate(gameId);
if (!ManifestCheck.IsValidVersion(request.Version)) throw ApiRejection.BadRequest("Numéro de version invalide.");
if (!PublishingLimits.IsValidSha256(request.ManifestSha256)) throw ApiRejection.BadRequest("Empreinte du manifest invalide.");
if (request.ManifestSize is <= 0 or > PublishingLimits.MaxManifestBytes) throw ApiRejection.BadRequest("Taille du manifest invalide.");
lock (_startLock)
{
CleanUp(force: true);
PublicationRecord? replaced = null;
foreach (var existing in Records().Where(r => string.Equals(r.GameId, key, StringComparison.OrdinalIgnoreCase)).ToList())
{
if (existing.AdminId == adminId && existing.Version == request.Version &&
string.Equals(existing.ManifestSha256, request.ManifestSha256, StringComparison.OrdinalIgnoreCase))
{
Touch(existing.Id);
return (Lock(existing.Id, () => State(Current(existing.Id), resumed: true)), null);
}
var idle = _time.GetUtcNow() - LastActivity(existing.Id);
if (existing.AdminId != adminId && idle < PublishingLimits.TakeoverAfter)
throw ApiRejection.Conflict(
$"{existing.AdminName} publie la version {existing.Version} de ce jeu (dernier envoi il y a " +
$"{Math.Max(1, (int)idle.TotalMinutes)} min). Réessayez plus tard.", ApiErrorCodes.PublicationInProgress);
Lock(existing.Id, () =>
{
Delete(existing);
return 0;
});
replaced = existing;
}
var current = PublishedFiles.ReadManifest(manifestPath);
var currentRoot = current is null ? null : ChangelogStore.Parse(current, key);
var record = new PublicationRecord
{
Id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
GameId = key,
Version = request.Version,
ManifestSha256 = request.ManifestSha256.ToLowerInvariant(),
ManifestSize = request.ManifestSize,
AdminId = adminId,
AdminName = adminName,
CreatedAt = _time.GetUtcNow(),
PublishedVersion = currentRoot is null ? "" : PublishedFiles.StringValue(currentRoot, "version") ?? "",
PublishedContentSha256 = ContentSignature(currentRoot)
};
CreateStaging(record);
Save(record);
return (Lock(record.Id, () => State(record, resumed: false)), replaced);
}
}
public PublicationState GetState(string id, Guid adminId)
{
Find(id, adminId);
return Lock(id, () =>
{
EnsurePlan(Current(id));
return State(Current(id), resumed: false);
});
}
/// <summary>Abandon : dépôt supprimé, rien n'est publié.</summary>
public PublicationRecord Cancel(string id, Guid adminId)
{
Find(id, adminId);
return Lock(id, () =>
{
var record = Current(id);
Delete(record);
return record;
});
}
// ================================================================== Envoi
public ChunkResult AppendManifest(string id, Guid adminId, long offset, byte[] data)
{
Find(id, adminId);
return Lock(id, () =>
{
var record = Current(id);
var staging = Staging(record);
var result = Append(Path.Combine(staging, "manifest.part"), Path.Combine(staging, "manifest.json"), offset, data,
record.ManifestSize, record.ManifestSha256, "manifest");
Touch(id);
if (result.Complete) EnsurePlan(record);
return result;
});
}
public ChunkResult AppendArchive(string id, Guid adminId, long offset, byte[] data)
{
Find(id, adminId);
return Lock(id, () =>
{
var record = Current(id);
var plan = RequirePlan(record);
if (!plan.ArchiveNeeded) return new ChunkResult { Received = plan.ArchiveSize, Complete = true };
var staging = Staging(record);
var result = Append(Path.Combine(staging, "archive.part"), Path.Combine(staging, "archive.zip"), offset, data,
plan.ArchiveSize, plan.ArchiveSha256, "archive");
Touch(id);
return result;
});
}
/// <summary>Gros objet, par morceaux.</summary>
public ChunkResult AppendObject(string id, Guid adminId, string sha256, long offset, byte[] data)
{
Find(id, adminId);
var sha = sha256.ToLowerInvariant();
return Lock(id, () =>
{
var record = Current(id);
var plan = RequirePlan(record);
if (!plan.Pending.TryGetValue(sha, out var size))
throw ApiRejection.BadRequest("Cet objet n'est pas attendu par cette publication.");
var objects = StagingObjects(record);
var result = Append(Path.Combine(objects, sha + ".part"), Path.Combine(objects, sha + ".bin"), offset, data, size, sha, "objet");
Touch(id);
return result;
});
}
/// <summary>Lot de petits objets (chacun vérifié) ; renvoie le nombre d'objets enregistrés.</summary>
public ChunkResult AddObjects(string id, Guid adminId, byte[] body)
{
Find(id, adminId);
List<(string Sha256, ReadOnlyMemory<byte> Data)> batch;
try
{
batch = ObjectBatch.Read(body, PublishingLimits.MaxObjectsPerBatch);
}
catch (InvalidDataException ex)
{
throw ApiRejection.BadRequest(ex.Message);
}
return Lock(id, () =>
{
var record = Current(id);
var plan = RequirePlan(record);
var objects = StagingObjects(record);
var saved = 0;
foreach (var (sha, data) in batch)
{
if (!plan.Pending.TryGetValue(sha, out var size))
throw ApiRejection.BadRequest("Un objet du lot n'est pas attendu par cette publication.");
if (data.Length != size) throw ApiRejection.BadRequest($"Objet {sha[..12]}… : taille différente de celle du manifest.");
if (PublishedFiles.Sha256(data.Span) != sha)
throw ApiRejection.BadRequest($"Objet {sha[..12]}… : contenu différent de son empreinte (fichier modifié ou abîmé).");
var done = Path.Combine(objects, sha + ".bin");
if (!File.Exists(done))
{
var temp = Path.Combine(objects, $"{sha}.{Guid.NewGuid():N}.tmp");
PublishedFiles.WriteNew(temp, data.Span, PrivateFile);
File.Move(temp, done, overwrite: true);
}
saved++;
}
Touch(id);
return new ChunkResult { Received = saved, Complete = true };
});
}
// ================================================================== Mise en ligne
/// <summary>
/// Met la version en ligne : objets, archive, puis manifest (historique des notes du serveur conservé).
/// 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)
{
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);
Find(id, adminId);
return Lock(id, () =>
{
if (Load(id) is null && LoadDone(id) is { } finished && finished.AdminId == adminId) return (finished.Result, (PublicationRecord?)null);
var record = Current(id);
var (manifestPath, gameId) = Locate(record.GameId);
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
var plan = RequirePlan(record);
var state = State(record, resumed: false);
if (state.PendingObjects.Count > 0 || !state.ArchiveComplete)
throw ApiRejection.Conflict(
$"Envoi incomplet : {state.PendingObjects.Count} fichier(s) et {(state.ArchiveComplete ? "aucune archive" : "l'archive")} restent à envoyer.");
var staging = Staging(record);
var manifestBytes = File.ReadAllBytes(Path.Combine(staging, "manifest.json"));
var errors = new List<string>();
var summary = ManifestCheck.Check(manifestBytes, gameId, record.Version, errors)
?? throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
using (PublishedFiles.LockGame(_dataDirectory, gameId))
{
var current = PublishedFiles.ReadManifest(manifestPath);
var currentRoot = current is null ? null : ChangelogStore.Parse(current, gameId);
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)
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).
var root = PublishedFiles.ParseObject(PublishedFiles.StripBom(manifestBytes), "Manifest");
PublishedFiles.SetProperty(root, "changelog", MergeChangelog(root, currentRoot, record.Version));
var newBytes = PublishedFiles.Serialize(root);
// 0. Contrôles AVANT tout déplacement : droits, liens symboliques, dossiers objects/xx.
if (new FileInfo(manifestPath).LinkTarget is not null)
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
var archiveTarget = Path.Combine(gameDirectory, summary.ArchiveName);
if (new FileInfo(archiveTarget).LinkTarget is not null)
throw new ManifestWriteRefusedException("l'archive publiée est un lien symbolique : la remplacer romprait le lien.");
var mode = PublishedFiles.PublicMode(gameDirectory, current is null ? null : manifestPath);
var objectsDirectory = Path.Combine(gameDirectory, "objects");
PublishedFiles.EnsurePublicDirectory(objectsDirectory, gameDirectory, keepSetGid: true);
foreach (var prefix in plan.Pending.Keys.Select(sha => sha[..2]).Distinct())
PublishedFiles.EnsurePublicDirectory(Path.Combine(objectsDirectory, prefix), gameDirectory, keepSetGid: false);
// 1. Objets (contenu adressé par l'empreinte : un objet présent est le bon s'il a la bonne taille).
var added = 0;
foreach (var (sha, size) in plan.Pending)
{
var staged = Path.Combine(StagingObjects(record), sha + ".bin");
var target = Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha));
if (ObjectPresent(target, size))
{
PublishedFiles.TryDelete(staged);
continue;
}
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(staged, mode);
File.Move(staged, target, overwrite: true);
added++;
}
foreach (var (sha, size) in summary.Objects)
if (!ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
throw ApiRejection.Conflict(
$"Le fichier {ManifestCheck.ObjectPath(sha)} a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
// 2. Archive : l'ancienne (même nom : republication) est gardée de côté jusqu'à l'écriture du manifest.
var stagedArchive = Path.Combine(staging, "archive.zip");
string? previousArchive = null;
var archiveAdded = false;
if (File.Exists(stagedArchive))
{
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(stagedArchive, mode);
if (File.Exists(archiveTarget))
{
previousArchive = PublishedFiles.TempPath(archiveTarget);
File.Move(archiveTarget, previousArchive);
}
File.Move(stagedArchive, archiveTarget);
archiveAdded = true;
}
else if (!File.Exists(archiveTarget) || new FileInfo(archiveTarget).Length != summary.ArchiveSize)
{
throw ApiRejection.Conflict("L'archive a disparu du serveur pendant l'envoi : abandonnez puis recommencez la publication.");
}
// 3. Manifest en dernier : les launchers ne voient jamais une version incomplète.
try
{
if (current is not null) PublishedFiles.Backup(_dataDirectory, gameId, current);
PublishedFiles.WriteManifest(manifestPath, newBytes, current is null ? null : PublishedFiles.Sha256(current),
() => ApiRejection.Conflict("Le manifest a été modifié pendant la mise en ligne : recommencez.", ApiErrorCodes.RevisionConflict));
}
catch
{
// Archive remise dans le dépôt (pas besoin de la renvoyer), l'ancienne remise en ligne.
if (archiveAdded)
{
File.Move(archiveTarget, stagedArchive, overwrite: true);
if (previousArchive is not null) File.Move(previousArchive, archiveTarget);
}
throw;
}
if (previousArchive is not null) PublishedFiles.TryDelete(previousArchive);
var result = new PublicationResult
{
GameId = gameId,
Version = record.Version,
PreviousVersion = currentVersion,
ManifestSha256 = PublishedFiles.Sha256(newBytes),
ObjectsAdded = added,
ArchiveAdded = archiveAdded
};
SaveDone(new PublicationDone { Id = id, AdminId = record.AdminId, Result = result, At = _time.GetUtcNow() });
Delete(record);
return (result, (PublicationRecord?)record);
}
});
}
/// <summary>
/// Notes : celles de la version publiée (du manifest reçu ; à défaut, celles déjà en ligne), puis
/// l'historique du SERVEUR (qui contient les corrections faites en ligne), dans les bornes du launcher
/// (30 versions ; les plus anciennes retirées si l'ensemble est trop long).
/// Sans manifest en ligne : les notes du manifest reçu.
/// </summary>
private static JsonArray? MergeChangelog(JsonObject uploaded, JsonObject? current, string version)
{
var received = ChangelogStore.ChangelogArray(uploaded);
if (current is null) return received?.DeepClone().AsArray();
static bool Is(JsonNode? node, string version) =>
node is JsonObject entry && PublishedFiles.StringValue(entry, "version") == version;
var history = ChangelogStore.ChangelogArray(current) ?? new JsonArray();
var merged = new JsonArray();
var head = received?.FirstOrDefault(n => Is(n, version)) ?? history.FirstOrDefault(n => Is(n, version));
if (head is not null) merged.Add(head.DeepClone());
foreach (var entry in history)
{
if (merged.Count >= ChangelogLimits.MaxEntries) break;
if (entry is JsonObject && !Is(entry, version)) merged.Add(entry.DeepClone());
}
// Budget de tout l'historique (contrôle de lecture en même temps : une entrée illisible empêche la publication).
while (merged.Count > 1 &&
ChangelogStore.Entries(merged).OfType<ChangelogEntry>().Sum(ChangelogLimits.DisplayedItems) > ChangelogLimits.MaxTotalItems)
merged.RemoveAt(merged.Count - 1);
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)
{
if (root is null) return "";
var copy = root.DeepClone().AsObject();
PublishedFiles.SetProperty(copy, "changelog", null);
return PublishedFiles.Sha256(System.Text.Encoding.UTF8.GetBytes(copy.ToJsonString()));
}
// ================================================================== Plan (après réception du manifest)
/// <summary>Manifest complet : contrôle, puis liste des objets et de l'archive à envoyer.</summary>
private void EnsurePlan(PublicationRecord record)
{
if (record.Plan is not null) return;
var staging = Staging(record);
var manifest = Path.Combine(staging, "manifest.json");
if (!File.Exists(manifest)) return;
var (manifestPath, gameId) = Locate(record.GameId);
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
var errors = new List<string>();
var summary = ManifestCheck.Check(File.ReadAllBytes(manifest), gameId, record.Version, errors);
if (summary is null)
{
Delete(record); // manifest refusé : la publication est abandonnée (à refaire avec un manifest corrigé)
throw ApiRejection.BadRequest("Manifest refusé : " + string.Join(" ", errors.Take(5)) +
(errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
}
var pending = summary.Objects
.Where(o => !ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(o.Key)), o.Value))
.ToDictionary(o => o.Key, o => o.Value, StringComparer.Ordinal);
// Archive déjà en ligne (même nom, même taille, même empreinte DANS le manifest en ligne) : pas renvoyée.
// Aucune lecture du fichier (plusieurs Go) pendant la requête.
var archiveTarget = new FileInfo(Path.Combine(gameDirectory, summary.ArchiveName));
var archiveNeeded = !(archiveTarget.Exists && archiveTarget.LinkTarget is null && archiveTarget.Length == summary.ArchiveSize &&
PublishedArchive(manifestPath, gameId) is { } online &&
string.Equals(online.Name, summary.ArchiveName, StringComparison.Ordinal) && online.Sha256 == summary.ArchiveSha256);
var needed = pending.Values.Sum() + (archiveNeeded ? summary.ArchiveSize : 0);
var free = new DriveInfo(gameDirectory).AvailableFreeSpace;
if (free < needed + FreeSpaceMargin)
throw new ApiRejection(507, ApiErrorCodes.InsufficientStorage,
$"Espace disque insuffisant sur le serveur : {needed / (1024 * 1024)} Mo à recevoir, {free / (1024 * 1024)} Mo libres.");
Save(record with
{
Plan = new PublicationPlan
{
ArchiveName = summary.ArchiveName,
ArchiveSize = summary.ArchiveSize,
ArchiveSha256 = summary.ArchiveSha256,
ArchiveNeeded = archiveNeeded,
Pending = pending,
ObjectCount = summary.Objects.Count,
ObjectsOnServer = summary.Objects.Count - pending.Count
}
});
}
/// <summary>Archive référencée par le manifest en ligne (nom, empreinte), sans la lire.</summary>
private static (string Name, string Sha256)? PublishedArchive(string manifestPath, string gameId)
{
var bytes = PublishedFiles.ReadManifest(manifestPath);
if (bytes is null) return null;
var root = ChangelogStore.Parse(bytes, gameId);
if (PublishedFiles.Property(root, "archive") is not JsonObject archive) return null;
var name = PublishedFiles.StringValue(archive, "url");
var sha = PublishedFiles.StringValue(archive, "sha256");
return name is null || sha is null ? null : (name, sha.ToLowerInvariant());
}
private PublicationPlan RequirePlan(PublicationRecord record)
{
EnsurePlan(record);
return Current(record.Id).Plan ?? throw ApiRejection.Conflict("Envoyez d'abord le manifest.");
}
private PublicationState State(PublicationRecord record, bool resumed)
{
var staging = Staging(record);
var manifestDone = File.Exists(Path.Combine(staging, "manifest.json"));
var plan = record.Plan;
var pending = new List<string>();
var partial = new Dictionary<string, long>(StringComparer.Ordinal);
long archiveReceived = 0;
var archiveComplete = false;
if (plan is not null)
{
var (manifestPath, _) = Locate(record.GameId);
var gameDirectory = Path.GetDirectoryName(manifestPath)!;
var objects = Path.Combine(staging, "objects");
foreach (var (sha, size) in plan.Pending)
{
if (File.Exists(Path.Combine(objects, sha + ".bin")) ||
ObjectPresent(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(sha)), size))
continue;
pending.Add(sha);
var part = new FileInfo(Path.Combine(objects, sha + ".part"));
if (part.Exists && part.Length > 0) partial[sha] = part.Length;
}
archiveComplete = !plan.ArchiveNeeded || File.Exists(Path.Combine(staging, "archive.zip"));
archiveReceived = archiveComplete ? plan.ArchiveSize : Length(Path.Combine(staging, "archive.part"));
}
return new PublicationState
{
PublicationId = record.Id,
GameId = record.GameId,
Version = record.Version,
PublishedVersion = record.PublishedVersion,
Resumed = resumed,
ManifestSize = record.ManifestSize,
ManifestReceived = manifestDone ? record.ManifestSize : Length(Path.Combine(staging, "manifest.part")),
ManifestAccepted = plan is not null,
ObjectCount = plan?.ObjectCount ?? 0,
ObjectsOnServer = plan?.ObjectsOnServer ?? 0,
PendingObjects = pending,
PartialObjects = partial,
ArchiveName = plan?.ArchiveName,
ArchiveSize = plan?.ArchiveSize ?? 0,
ArchiveReceived = archiveReceived,
ArchiveComplete = archiveComplete,
ExpiresAt = LastActivity(record.Id) + PublishingLimits.IdleExpiry
};
}
// ================================================================== Réception d'un fichier
/// <summary>
/// Ajoute un morceau à la suite de ce qui a été reçu (position vérifiée), puis vérifie l'empreinte
/// quand le fichier est complet (calculée au fil des morceaux ; recalculée après un redémarrage).
/// </summary>
private ChunkResult Append(string partPath, string donePath, long offset, byte[] data, long size, string sha256, string what)
{
if (File.Exists(donePath)) return new ChunkResult { Received = size, Complete = true };
var received = Length(partPath);
if (offset != received)
throw ApiRejection.Conflict($"Position d'envoi {offset} inattendue ({what} : {received} octets déjà reçus).", ApiErrorCodes.OffsetMismatch);
if (received + data.LongLength > size) throw ApiRejection.BadRequest($"Données en trop ({what} : {size} octets attendus).");
if (data.Length == 0 && size > 0) throw ApiRejection.BadRequest("Morceau vide.");
var options = new FileStreamOptions { Mode = FileMode.Append, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PrivateFile;
using (var stream = new FileStream(partPath, options))
{
stream.Write(data);
stream.Flush(flushToDisk: true);
}
HashProgress? progress;
if (received == 0)
{
RemoveHash(partPath);
progress = _hashes[partPath] = new HashProgress();
}
else if (!_hashes.TryGetValue(partPath, out progress) || progress.Offset != received)
{
RemoveHash(partPath);
progress = null;
}
progress?.Add(data);
received += data.Length;
if (received < size) return new ChunkResult { Received = received };
var actual = progress is not null && progress.Offset == size ? progress.Finish() : HashFile(partPath);
RemoveHash(partPath);
if (actual != sha256)
{
PublishedFiles.TryDelete(partPath);
throw ApiRejection.BadRequest($"Empreinte différente pour {what} : fichier abîmé ou modifié pendant l'envoi ; il sera renvoyé.");
}
File.Move(partPath, donePath, overwrite: true);
return new ChunkResult { Received = size, Complete = true };
}
private void RemoveHash(string key)
{
if (_hashes.TryRemove(key, out var progress)) progress.Dispose();
}
private sealed class HashProgress : IDisposable
{
private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
public long Offset { get; private set; }
public void Add(byte[] data)
{
_hash.AppendData(data);
Offset += data.Length;
}
public string Finish() => Convert.ToHexString(_hash.GetHashAndReset()).ToLowerInvariant();
public void Dispose() => _hash.Dispose();
}
// ================================================================== Enregistrements, dépôt
public sealed record PublicationRecord
{
public string Id { get; init; } = "";
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
public string ManifestSha256 { get; init; } = "";
public long ManifestSize { get; init; }
public Guid AdminId { get; init; }
public string AdminName { get; init; } = "";
public DateTimeOffset CreatedAt { get; init; }
/// <summary>Version en ligne au début (vide : aucune).</summary>
public string PublishedVersion { get; init; } = "";
/// <summary>Empreinte du manifest en ligne SANS les notes, au début : la mise en ligne est refusée si elle a changé.</summary>
public string PublishedContentSha256 { get; init; } = "";
public PublicationPlan? Plan { get; init; }
}
public sealed record PublicationPlan
{
public string ArchiveName { get; init; } = "";
public long ArchiveSize { get; init; }
public string ArchiveSha256 { get; init; } = "";
public bool ArchiveNeeded { get; init; }
/// <summary>Objets absents du serveur quand le manifest a été reçu (empreinte → taille).</summary>
public Dictionary<string, long> Pending { get; init; } = new(StringComparer.Ordinal);
public int ObjectCount { get; init; }
public int ObjectsOnServer { get; init; }
}
/// <summary>Publication terminée : résultat gardé 24 h (réponse perdue, nouvel essai).</summary>
public sealed record PublicationDone
{
public string Id { get; init; } = "";
public Guid AdminId { get; init; }
public PublicationResult Result { get; init; } = new();
public DateTimeOffset At { get; init; }
}
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, "Publication");
}
/// <summary>Publication de cet administrateur, encore valable (sinon 404/403).</summary>
private PublicationRecord Find(string id, Guid adminId)
{
if (!PublishingLimits.IsValidPublicationId(id)) throw ApiRejection.NotFound("Publication introuvable.");
CleanUp(force: false);
var record = Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
if (record.AdminId != adminId)
throw new ApiRejection(403, ApiErrorCodes.Forbidden, $"Cette publication a été lancée par {record.AdminName}.");
return record;
}
/// <summary>Relue sous le verrou : abandonnée entre-temps → 404.</summary>
private PublicationRecord Current(string id) =>
Load(id) ?? throw ApiRejection.NotFound("Publication introuvable (terminée, abandonnée ou expirée).");
private string RecordPath(string id) => Path.Combine(_recordsDirectory, id + ".json");
private string DonePath(string id) => Path.Combine(_recordsDirectory, id + DoneSuffix + ".json");
private PublicationRecord? Load(string id) => Read<PublicationRecord>(RecordPath(id));
private PublicationDone? LoadDone(string id) => Read<PublicationDone>(DonePath(id));
private static T? Read<T>(string path) where T : class
{
if (!File.Exists(path)) return null;
try
{
return JsonSerializer.Deserialize<T>(File.ReadAllText(path), ApiJson.Options);
}
catch (Exception ex) when (ex is JsonException or IOException)
{
return null;
}
}
private IEnumerable<PublicationRecord> Records()
{
if (!Directory.Exists(_recordsDirectory)) yield break;
foreach (var file in Directory.GetFiles(_recordsDirectory, "*.json"))
{
var id = Path.GetFileNameWithoutExtension(file);
if (PublishingLimits.IsValidPublicationId(id) && Load(id) is { } record) yield return record;
}
}
private void Save(PublicationRecord record)
{
SecureFiles.EnsureDirectory(_recordsDirectory);
SecureFiles.WriteAllTextAtomic(RecordPath(record.Id), JsonSerializer.Serialize(record, ApiJson.Options), keepBackup: false);
}
private void SaveDone(PublicationDone done)
{
SecureFiles.EnsureDirectory(_recordsDirectory);
SecureFiles.WriteAllTextAtomic(DonePath(done.Id), JsonSerializer.Serialize(done, ApiJson.Options), keepBackup: false);
}
/// <summary>Dernier envoi : date de modification de l'enregistrement (mise à jour à chaque morceau).</summary>
private DateTimeOffset LastActivity(string id)
{
var path = RecordPath(id);
return File.Exists(path) ? new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero) : DateTimeOffset.MinValue;
}
private void Touch(string id)
{
try
{
File.SetLastWriteTimeUtc(RecordPath(id), _time.GetUtcNow().UtcDateTime);
}
catch (IOException)
{
// simple indication d'activité
}
}
private string Staging(PublicationRecord record) =>
Path.Combine(Path.GetDirectoryName(Locate(record.GameId).Path)!, StagingPrefix + record.Id);
private string StagingObjects(PublicationRecord record)
{
var objects = Path.Combine(Staging(record), "objects");
if (!Directory.Exists(objects)) CreatePrivateDirectory(objects);
return objects;
}
private void CreateStaging(PublicationRecord record)
{
var staging = Staging(record);
if (new DirectoryInfo(Path.GetDirectoryName(staging)!).LinkTarget is not null)
throw new ManifestWriteRefusedException("le dossier du jeu est un lien symbolique.");
CreatePrivateDirectory(staging);
}
private static void CreatePrivateDirectory(string path)
{
if (OperatingSystem.IsWindows()) Directory.CreateDirectory(path);
else Directory.CreateDirectory(path, PrivateDirectory);
}
private void Delete(PublicationRecord record)
{
foreach (var key in _hashes.Keys.Where(k => k.Contains(StagingPrefix + record.Id, StringComparison.Ordinal)).ToList())
RemoveHash(key);
try
{
var staging = Staging(record);
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
{
// dépôt laissé : supprimé au prochain nettoyage
}
PublishedFiles.TryDelete(RecordPath(record.Id));
_locks.TryRemove(record.Id, out _);
}
/// <summary>
/// Publications sans envoi depuis 48 h, résultats de plus de 24 h et dépôts orphelins : supprimés
/// (au début d'une publication, et au plus toutes les 10 minutes lors des envois).
/// </summary>
private void CleanUp(bool force)
{
var now = _time.GetUtcNow();
lock (_locks)
{
if (!force && now - _lastCleanUp < CleanUpEvery) return;
_lastCleanUp = now;
}
var known = new HashSet<string>(StringComparer.Ordinal);
foreach (var record in Records().ToList())
{
if (now - LastActivity(record.Id) > PublishingLimits.IdleExpiry)
Lock(record.Id, () =>
{
Delete(record);
return 0;
});
else known.Add(record.Id);
}
if (Directory.Exists(_recordsDirectory))
foreach (var done in Directory.GetFiles(_recordsDirectory, "*" + DoneSuffix + ".json"))
if (now - new DateTimeOffset(File.GetLastWriteTimeUtc(done), TimeSpan.Zero) > DoneLifetime)
PublishedFiles.TryDelete(done);
foreach (var manifest in _manifests.Values)
{
var directory = Path.GetDirectoryName(manifest)!;
if (!Directory.Exists(directory)) continue;
foreach (var staging in Directory.GetDirectories(directory, StagingPrefix + "*"))
{
var id = Path.GetFileName(staging)[StagingPrefix.Length..];
if (known.Contains(id) || now - Directory.GetLastWriteTimeUtc(staging) <= PublishingLimits.IdleExpiry) continue;
try
{
Directory.Delete(staging, recursive: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// nouvel essai au prochain nettoyage
}
}
}
}
private T Lock<T>(string id, Func<T> action)
{
lock (_locks.GetOrAdd(id, _ => new object())) return action();
}
private static bool ObjectPresent(string path, long size)
{
var info = new FileInfo(path);
return info.Exists && info.LinkTarget is null && info.Length == size;
}
private static long Length(string path)
{
var info = new FileInfo(path);
return info.Exists ? info.Length : 0;
}
private static string HashFile(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -0,0 +1,281 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Fichiers PUBLICS d'un jeu (dossier servi par nginx : manifest.json, archive, objects/), communs aux
/// notes de version (phase 4) et à la publication d'une version (phase 5) :
/// • même verrou par jeu (une seule écriture du manifest à la fois, service et outils confondus) ;
/// • même présentation JSON que ManifestBuilder ;
/// • écriture atomique : fichier temporaire caché (refusé par nginx) dans le même dossier, puis renommage ;
/// • droits : ceux du manifest existant (sinon 0640 avec le groupe du dossier setgid, ou 0644) ;
/// refus si le dossier n'a pas le bit setgid alors que nginx lit les fichiers par leur groupe,
/// ou si le manifest est un lien symbolique.
/// </summary>
internal static class PublishedFiles
{
/// <summary>Taille maximale d'un manifest lu ou écrit (même borne que le launcher).</summary>
public const long MaxManifestBytes = 64L * 1024 * 1024;
public static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10);
private const string HistoryFolder = "manifest-history";
private const int HistoryLength = 20;
private const UnixFileMode GroupReadable = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead;
private const UnixFileMode AllReadable = GroupReadable | UnixFileMode.OtherRead;
/// <summary>Même présentation que ManifestBuilder (indenté, camelCase, accents non échappés).</summary>
public static readonly JsonSerializerOptions ManifestJson = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
MaxDepth = 64
};
public static readonly JsonDocumentOptions ReadOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 64
};
/// <summary>Verrou d'écriture du manifest d'un jeu (fichier dans les données privées).</summary>
public static FileStream LockGame(string dataDirectory, string gameId) =>
SecureFiles.AcquireLock(Path.Combine(dataDirectory, $"manifest-{gameId.ToLowerInvariant()}.lock"), LockTimeout);
public static string Sha256(ReadOnlySpan<byte> bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
public static ReadOnlySpan<byte> StripBom(byte[] bytes) =>
bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ? bytes.AsSpan(3) : bytes;
/// <summary>Manifest publié (null s'il n'existe pas encore).</summary>
public static byte[]? ReadManifest(string path)
{
var info = new FileInfo(path);
if (!info.Exists) return null;
if (info.Length > MaxManifestBytes) throw new InvalidDataException("Manifest trop volumineux.");
return File.ReadAllBytes(path);
}
public static JsonObject ParseObject(ReadOnlySpan<byte> bytes, string what)
{
try
{
return JsonNode.Parse(bytes, documentOptions: ReadOptions) as JsonObject
?? throw new InvalidDataException($"{what} : objet JSON attendu.");
}
catch (Exception ex) when (ex is JsonException or ArgumentException)
{
throw new InvalidDataException($"{what} illisible : {ex.Message}", ex);
}
}
/// <summary>Propriété d'un objet JSON, nom insensible à la casse (comme pour le launcher).</summary>
public static JsonNode? Property(JsonObject node, string name)
{
foreach (var (key, value) in node)
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
return value;
return null;
}
public static string? StringValue(JsonObject node, string name) =>
Property(node, name) is JsonValue v && v.TryGetValue<string>(out var text) ? text : null;
/// <summary>Remplace une propriété (toutes casses confondues) ; null la retire.</summary>
public static void SetProperty(JsonObject node, string name, JsonNode? value)
{
foreach (var existing in node.Select(p => p.Key).Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList())
node.Remove(existing);
if (value is not null) node[name] = value;
}
public static byte[] Serialize(JsonObject root)
{
var bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(root.ToJsonString(ManifestJson));
if (bytes.LongLength > MaxManifestBytes) throw ApiRejection.BadRequest("Manifest trop volumineux.");
return bytes;
}
/// <summary>Copie de l'ancien manifest dans les données privées (aide à la récupération ; 20 dernières).</summary>
public static void Backup(string dataDirectory, string gameId, byte[] bytes)
{
try
{
var directory = Path.Combine(dataDirectory, HistoryFolder, gameId.ToLowerInvariant());
SecureFiles.EnsureDirectory(Path.Combine(dataDirectory, HistoryFolder));
SecureFiles.EnsureDirectory(directory);
var name = $"manifest-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Sha256(bytes)[..8]}.json";
SecureFiles.WriteAllTextAtomic(Path.Combine(directory, name), Encoding.UTF8.GetString(StripBom(bytes)), keepBackup: false);
foreach (var old in Directory.GetFiles(directory, "manifest-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// La copie est une aide : son échec n'empêche pas l'écriture.
}
}
/// <summary>
/// Droits d'un nouveau fichier public du dossier : ceux du manifest existant, sinon lisible par le
/// groupe du dossier (setgid) ou par tous. Refus si nginx risque de perdre l'accès.
/// </summary>
public static UnixFileMode PublicMode(string directory, string? referenceFile)
{
if (OperatingSystem.IsWindows()) return AllReadable;
var directoryMode = File.GetUnixFileMode(directory);
var setgid = directoryMode.HasFlag(UnixFileMode.SetGroup);
var mode = referenceFile is not null && File.Exists(referenceFile)
? File.GetUnixFileMode(referenceFile)
: setgid ? GroupReadable : AllReadable;
// Le fichier créé appartient au compte du service, avec le groupe du dossier SEULEMENT si celui-ci
// a le bit setgid : sinon nginx (groupe www-data) ne pourrait plus lire un fichier lisible par le groupe.
if (!mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.GroupRead) && !setgid)
throw new ManifestWriteRefusedException(
"le dossier du jeu n'a pas le bit setgid (chmod g+s) : les fichiers écrits ne seraient plus lisibles par nginx.");
// Fichier existant : ses droits sont repris tels quels (rw des comptes compris) ; nouveau fichier : lecture.
return mode & (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute);
}
/// <summary>
/// Sous-dossier public (objects, objects/ab) : droits du dossier du jeu, fixés explicitement (le masque
/// du service, UMask=0077, retirerait l'accès de nginx). Refus s'il s'agit d'un lien symbolique.
/// <paramref name="keepSetGid"/> : dossier qui en contiendra d'autres (objects) ; son bit setgid doit être
/// conservé pour que les sous-dossiers créés ensuite aient le groupe lu par nginx. (Un dossier
/// « feuille » garde de toute façon le groupe hérité à sa création, comme les fichiers qui y sont déplacés.)
/// </summary>
public static void EnsurePublicDirectory(string path, string gameDirectory, bool keepSetGid)
{
var info = new DirectoryInfo(path);
if (info.LinkTarget is not null)
throw new ManifestWriteRefusedException($"« {Path.GetFileName(path)} » est un lien symbolique dans le dossier du jeu.");
if (info.Exists) return;
if (OperatingSystem.IsWindows())
{
Directory.CreateDirectory(path);
return;
}
var gameMode = File.GetUnixFileMode(gameDirectory);
var permissions = gameMode & PermissionBits;
var needSetGid = keepSetGid && gameMode.HasFlag(UnixFileMode.SetGroup);
// Jamais de bit spécial demandé : systemd l'interdit au service (RestrictSUIDSGID=yes, refus EPERM).
// Dans un dossier setgid, le noyau transmet de lui-même le bit setgid et le groupe (www-data).
Directory.CreateDirectory(path, permissions);
if ((File.GetUnixFileMode(path) & PermissionBits) != permissions) // réduits par le masque du service (UMask=0077)
{
try
{
// Fixer les droits retire le setgid hérité : sans conséquence pour un dossier « feuille » (son
// groupe reste celui hérité) ; « objects » doit le redemander, ce que systemd peut refuser.
File.SetUnixFileMode(path, needSetGid ? permissions | UnixFileMode.SetGroup : permissions);
}
catch (UnauthorizedAccessException) when (needSetGid)
{
// traité ci-dessous
}
}
// Sans setgid (ou si le compte du service n'appartient pas au groupe du dossier, Linux le retire), les
// sous-dossiers créés ensuite n'auraient plus le groupe lu par nginx.
if (needSetGid && File.GetUnixFileMode(path) != (permissions | UnixFileMode.SetGroup))
{
TryDeleteDirectory(path);
throw new ManifestWriteRefusedException(
"le dossier « objects » n'existe pas encore et le service ne peut pas le créer avec le bit setgid et l'accès de nginx : " +
"créez-le sur le VPS (server/deploy/LISEZMOI.md, étape 3 quater).");
}
}
/// <summary>Droits rwx des trois classes, sans setuid, setgid ni sticky.</summary>
private const UnixFileMode PermissionBits =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
private static void TryDeleteDirectory(string path)
{
try
{
Directory.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// laissé : vide, sans conséquence
}
}
/// <summary>
/// Remplace (ou crée) le manifest publié. <paramref name="expectedSha256"/> : empreinte du manifest
/// sur lequel l'écriture a été préparée (null : il ne devait pas exister) ; revérifiée juste avant le
/// renommage, pour ne jamais écraser un envoi (WinSCP…) fait entre-temps.
/// </summary>
public static void WriteManifest(string path, byte[] bytes, string? expectedSha256, Func<ApiRejection> conflict)
{
var directory = Path.GetDirectoryName(path)!;
if (new FileInfo(path).LinkTarget is not null)
throw new ManifestWriteRefusedException("le manifest publié est un lien symbolique : le remplacer romprait le lien.");
var mode = PublicMode(directory, path);
var temp = TempPath(path);
try
{
WriteNew(temp, bytes, mode);
var current = ReadManifest(path);
if (current is null ? expectedSha256 is not null : !string.Equals(Sha256(current), expectedSha256, StringComparison.OrdinalIgnoreCase))
throw conflict();
File.Move(temp, path, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
{
TryDelete(temp);
if (ex is ApiRejection or ManifestWriteRefusedException) throw;
throw new IOException($"Écriture du manifest impossible ({path}) : {ex.Message}", ex);
}
}
/// <summary>Fichier temporaire caché du même dossier (nginx refuse les noms commençant par un point).</summary>
public static string TempPath(string path) =>
Path.Combine(Path.GetDirectoryName(path)!, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
public static void WriteNew(string path, ReadOnlySpan<byte> bytes, UnixFileMode mode)
{
using (var stream = new FileStream(path, new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }))
{
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, mode);
}
public static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// laissé en place : sans conséquence (nom unique, caché)
}
}
public static ApiRejection NotConfigured(string gameId, string what) => new(503, ApiErrorCodes.NotConfigured,
$"{what} impossible pour « {gameId} » : manifest non configuré sur le serveur (LauncherApi:Games:{gameId}:ManifestFile).");
}
/// <summary>Écriture refusée par précaution (configuration du dossier à corriger) ; message sans chemin.</summary>
public sealed class ManifestWriteRefusedException : IOException
{
public ManifestWriteRefusedException(string message) : base(message) { }
}
@@ -31,8 +31,14 @@
"PublicFile": "",
"PublicUrl": ""
},
// 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": {},
"Limits": {
"RequestsPerMinute": 300,
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
"PublicationUploadsPerMinute": 1500,
"AuthPerMinute": 20,
"EnrollmentsPerHour": 10,
"MaxActiveInvitations": 20,
+107 -1
View File
@@ -124,6 +124,109 @@ serveur sont refusées.
Sans l'étape 2, la page Jeux reste consultable mais la publication est désactivée (bandeau orange).
## 3 ter. Notes de version modifiables en ligne (VPS)
La page **Notes de version** du menu d'administration corrige les notes des versions déjà publiées,
directement dans le `manifest.json` du jeu (rien d'autre n'y est modifié). Exemple ci-dessous avec le
dossier du jeu de « Serveur de téléchargement Exovival » (`/srv/exovival/www/exovivalzombie`) : adapte
le chemin au dossier réel, celui de `manifestUrl` dans `launcher.json`.
> **Compromis de sécurité** : le service pourra écrire dans le dossier du jeu (il remplace le manifest
> par renommage dans ce dossier). Un service compromis pourrait donc modifier les fichiers du jeu de ce
> dossier. L'autorisation se limite à ce seul dossier ; sans cette étape, rien n'est modifiable en ligne.
1. Vérifie que le dossier du jeu a le **bit setgid** (le `s` de `drwxr-s---`), pour que le manifest
remplacé garde le groupe `www-data` lu par nginx :
```
stat -c '%A %U:%G' /srv/exovival/www/exovivalzombie
sudo chmod g+s /srv/exovival/www/exovivalzombie # seulement s'il manque
```
Sans lui, le serveur refuse la modification plutôt que de rendre le manifest illisible.
2. Indique le manifest au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
avec tes autres réglages ; la clé est le `gameId` de `launcher.json`) :
```
{ "LauncherApi": { "Games": { "EZR": { "ManifestFile": "/srv/exovival/www/exovivalzombie/manifest.json" } } } }
```
3. Autorise le service à écrire dans ce dossier, et seulement lui :
```
sudo nano /etc/systemd/system/launcher-api.service.d/games.conf
```
Colle le contenu de `server/deploy/launcher-api-games.conf` (ligne `ReadWritePaths=` adaptée,
une par jeu), puis :
```
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
```
Ce fichier est conservé lors des mises à jour du serveur.
4. Sur ton PC, ajoute `--remote-manifest <adresse du manifest>` à ton script de publication
(déjà dans `tools\publish-example.bat`) : sans lui, la publication suivante écraserait les notes
corrigées en ligne avec celles du `manifest.json` local.
5. Teste : menu d'administration › **Notes de version**, corrige une faute, **Enregistrer**. L'ancien
manifest est copié dans `/var/lib/launcher-api/manifest-history/`.
N'enregistre pas de notes pendant l'envoi d'une nouvelle version avec WinSCP : le serveur le détecte
(empreinte du manifest) et refuse sans rien écraser ; recharge simplement la page ensuite.
## 3 quater. Publier une version depuis le launcher (VPS)
La page **Publier une version** du menu d'administration remplace l'envoi avec WinSCP : seuls les
fichiers absents du serveur sont envoyés, l'envoi reprend après une coupure, et le manifest est mis
en ligne en dernier. Prérequis : l'étape 3 ter (dossier du jeu déclaré dans `LauncherApi:Games`,
complément `games.conf`, bit setgid).
1. nginx : ajoute la zone de limite des envois dans `/etc/nginx/conf.d/securite.conf`, à côté de
`api_par_ip` (un envoi enchaîne plusieurs morceaux de 4 Mo par seconde ; avec la limite générale de
l'API, il serait bridé et ferait des pauses) :
```
limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
```
Puis colle le bloc `location ^~ /api/v1/publications/ { … }` de `server/deploy/nginx-publications.conf`
dans le bloc HTTPS du site, à côté du bloc `/api/`, et recharge :
```
sudo nginx -t && sudo systemctl reload nginx
```
Seules ces routes acceptent des envois de 8 Mo ; le reste de l'API reste limité à 64 Ko. Vérifie
aussi que le site contient toujours la règle `location ~ /\. { deny all; }` : les fichiers en cours
d'envoi sont rangés dans un dossier caché (`.publish-…`) du dossier du jeu.
2. Crée le dossier `objects` du jeu **et ses 256 sous-dossiers** (`00` à `ff`), avec le groupe et le bit
setgid du dossier du jeu (sans risque s'ils existent déjà : leurs droits sont simplement remis en état) :
```
JEU=/srv/exovival/www/exovivalzombie
sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects"
for i in $(seq 0 255); do sudo install -d -m 2750 -o deploy -g www-data "$JEU/objects/$(printf %02x $i)"; done
```
Pourquoi : systemd interdit au service de demander le bit setgid (`RestrictSUIDSGID=yes`, gardé). Un
sous-dossier qui manquerait serait créé par le service en `drwxr-x---` (groupe `www-data`, lisible par
nginx) mais **sans setgid** : un fichier déposé ensuite dedans **avec WinSCP** prendrait le groupe
`deploy` et ne serait plus lisible par nginx. Relance la boucle si `stat` montre un `drwxr-x---`.
3. Espace disque : l'envoi est stocké sur le même disque que le jeu jusqu'à la mise en ligne. Prévois la
taille de l'archive et des nouveaux fichiers (le serveur refuse l'envoi s'il manque de place).
4. Teste : lance le .bat de publication (ManifestBuilder) sur ton PC, puis menu d'administration ›
**Publier une version** › « Choisir le manifest.json… » (dossier `--output`) › **Publier**.
> **Déjà fait avant la correction des pauses ?** Ajoute la ligne `limit_req_zone … publication_par_ip …`
> ci-dessus, remplace dans le bloc `/api/v1/publications/` du site `limit_req zone=api_par_ip burst=20 nodelay;`
> par `limit_req zone=publication_par_ip burst=50 nodelay;`, recharge nginx, puis mets à jour le serveur
> (étape 8) : il a désormais sa propre limite pour les envois (`LauncherApi:Limits:PublicationUploadsPerMinute`,
> 1500 par minute par défaut, rien à ajouter dans `appsettings.Production.json`).
Une publication interrompue (fermeture du launcher, coupure) reprend quand tu cliques de nouveau sur
« Publier » avec le même dossier ; sans reprise, elle est supprimée au bout de 48 h. WinSCP reste
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.
## 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 :
@@ -191,7 +294,7 @@ d'autre ne peut approuver une clé à son nom. S'il a perdu tous ses PC : voir
## 8. Mettre à jour le serveur
Refais les étapes 1 et 2. Les administrateurs, l'audit et `appsettings.Production.json` sont
conservés, ainsi que le complément systemd du catalogue (`launcher-api.service.d/catalog.conf`) ;
conservés, ainsi que les compléments systemd (`launcher-api.service.d/catalog.conf` et `games.conf`) ;
les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
pas, le script remet la précédente en place.
@@ -243,10 +346,13 @@ recréer les administrateurs.
├─ audit/audit-AAAA-MM.jsonl journal d'audit
├─ catalog.json (+ .bak) catalogue publié (référence de la révision)
├─ catalog-history/ 20 dernières versions publiées
├─ manifest-history/<jeu>/ manifests remplacés (notes de version, publications ; 20 derniers)
├─ publications/ publications en cours (version, fichiers restant à envoyer)
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
└─ images/ images du catalogue (envoyées en SFTP)
/etc/systemd/system/launcher-api.service
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
/etc/systemd/system/launcher-api.service.d/games.conf écriture du dossier de chaque jeu (conservé)
```
+17
View File
@@ -0,0 +1,17 @@
# =============================================================================
# Launcher API : autorisation de modifier le manifest.json des jeux (LISEZMOI.md, étape 3 ter).
# /etc/systemd/system/launcher-api.service.d/games.conf
#
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
# écrire dans le dossier de chaque jeu configuré (LauncherApi:Games:<id>:ManifestFile), et
# seulement là. Le remplacement du manifest se fait par renommage dans ce dossier : c'est le dossier
# entier qui doit être autorisé (pas le seul fichier).
# Compromis : un service compromis pourrait alors modifier les fichiers du jeu de ce dossier.
# Sans ce complément (et sans LauncherApi:Games), les notes ne sont pas modifiables en ligne.
#
# Fichier séparé : conservé lors des mises à jour du serveur. Adapter le chemin au dossier du jeu
# réellement servi par nginx (exemple de la documentation ci-dessous ; une ligne par jeu), puis :
# sudo systemctl daemon-reload && sudo systemctl restart launcher-api
# =============================================================================
[Service]
ReadWritePaths=/srv/exovival/www/exovivalzombie
+2
View File
@@ -8,6 +8,8 @@
# Données privées : /var/lib/launcher-api (créé par systemd, droits 700).
# Catalogue des jeux : écriture autorisée dans son seul dossier public par le complément
# /etc/systemd/system/launcher-api.service.d/catalog.conf (launcher-api-catalog.conf, LISEZMOI.md).
# Notes de version : écriture dans le dossier de chaque jeu par le complément games.conf
# (launcher-api-games.conf, LISEZMOI.md étape 3 ter).
#
# Vérifier le durcissement : systemd-analyze security launcher-api
# Journal : journalctl -u launcher-api -f
+39
View File
@@ -0,0 +1,39 @@
# =============================================================================
# Publication d'une version depuis le launcher — deux ajouts (voir LISEZMOI.md, étape 3 quater) :
#
# 1) Dans /etc/nginx/conf.d/securite.conf, à côté de la zone « api_par_ip » :
#
# limit_req_zone $binary_remote_addr zone=publication_par_ip:10m rate=25r/s;
#
# Un envoi enchaîne plusieurs morceaux de 4 Mo par seconde : la zone « api_par_ip » (5 par seconde)
# briderait l'envoi vers 20 Mo/s et provoquerait des pauses. 25 par seconde ≈ 100 Mo/s ; ces routes
# exigent une session d'administrateur et le droit de publier.
#
# 2) Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival, à côté du bloc
# « location ^~ /api/ { … } » (nginx-api.conf) : (le bloc ci-dessous)
#
# Puis : sudo nginx -t && sudo systemctl reload nginx
#
# Seules ces routes acceptent de gros envois (morceaux de 4 Mo, 8 Mo au plus) ; toutes les autres
# routes de l'API restent limitées à 64 Ko. Le préfixe le plus long l'emporte sur « /api/ ».
# Les fichiers en cours d'envoi sont rangés dans un dossier caché (.publish-…) du dossier du jeu :
# la règle « location ~ /\. { deny all; } » du site doit être présente (elle l'est dans le guide).
# =============================================================================
location ^~ /api/v1/publications/ {
limit_req zone=publication_par_ip burst=50 nodelay;
limit_req_status 429;
client_max_body_size 9m;
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 180s;
proxy_read_timeout 180s;
}
+71 -2
View File
@@ -1,6 +1,8 @@
using System.Security.Cryptography;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
@@ -118,6 +120,67 @@ public sealed class AdminSession : IAsyncDisposable
SendSignedAsync<CatalogState>(HttpMethod.Put, ApiRoutes.Catalog,
new CatalogUpdateRequest { BaseRevision = baseRevision, Catalog = catalog }, ct);
// ------------------------------------------------------------------ Notes de version (manifest.json publié)
/// <summary>Notes de version publiées d'un jeu, avec l'empreinte du manifest lu.</summary>
public Task<ChangelogState> GetChangelogAsync(string gameId, CancellationToken ct = default) =>
GetAsync<ChangelogState>(ApiRoutes.GameChangelog(CheckGameId(gameId)), ct);
/// <summary>
/// Remplace les notes d'UNE version existante (requête signée). <paramref name="baseSha256"/> : empreinte
/// du manifest sur lequel la modification a été préparée (409 revision_conflict s'il a changé).
/// </summary>
public Task<ChangelogState> SaveChangelogEntryAsync(string gameId, ChangelogEntry entry, string baseSha256, CancellationToken ct = default)
{
if (!ChangelogLimits.IsEditableVersion(entry.Version))
throw new ArgumentException($"Version « {entry.Version} » non modifiable en ligne.", nameof(entry));
return SendSignedAsync<ChangelogState>(HttpMethod.Put, ApiRoutes.GameChangelogEntry(CheckGameId(gameId), entry.Version),
new ChangelogUpdateRequest { BaseSha256 = baseSha256, Entry = entry }, ct);
}
// ------------------------------------------------------------------ Publication d'une version
/// <summary>Délai d'une requête d'envoi de fichier (morceau de 4 Mo sur une connexion lente).</summary>
public static readonly TimeSpan UploadTimeout = TimeSpan.FromMinutes(3);
public Task<PublicationState> StartPublicationAsync(string gameId, PublicationStartRequest request, CancellationToken ct = default) =>
SendSignedAsync<PublicationState>(HttpMethod.Post, ApiRoutes.PublicationStart(CheckGameId(gameId)), request, ct);
public Task<PublicationState> GetPublicationAsync(string id, CancellationToken ct = default) =>
GetAsync<PublicationState>(ApiRoutes.Publication(CheckPublicationId(id)), ct);
public Task CancelPublicationAsync(string id, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.Publication(CheckPublicationId(id)), null, ct);
public Task<ChunkResult> UploadManifestAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationManifest(CheckPublicationId(id), offset), data,
PublishingLimits.BinaryContentType, UploadTimeout, ct);
public Task<ChunkResult> UploadArchiveAsync(string id, long offset, byte[] data, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put, ApiRoutes.PublicationArchive(CheckPublicationId(id), offset), data,
PublishingLimits.BinaryContentType, UploadTimeout, ct);
public Task<ChunkResult> UploadObjectAsync(string id, string sha256, long offset, byte[] data, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Put,
ApiRoutes.PublicationObject(CheckPublicationId(id), PublishingLimits.IsValidSha256(sha256) ? sha256.ToLowerInvariant()
: throw new ArgumentException("Empreinte invalide.", nameof(sha256)), offset), data,
PublishingLimits.BinaryContentType, UploadTimeout, ct);
public Task<ChunkResult> UploadObjectBatchAsync(string id, byte[] batch, CancellationToken ct = default) =>
SendSignedBytesAsync<ChunkResult>(HttpMethod.Post, ApiRoutes.PublicationObjects(CheckPublicationId(id)), batch,
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);
private static string CheckPublicationId(string id) =>
PublishingLimits.IsValidPublicationId(id) ? id : throw new ArgumentException("Identifiant de publication invalide.", nameof(id));
private static string CheckGameId(string gameId) =>
CatalogRules.IsValidGameId(gameId) ? gameId : throw new ArgumentException($"Identifiant de jeu invalide : « {gameId} ».", nameof(gameId));
// ------------------------------------------------------------------ Requêtes génériques (modules)
public Task<T> GetAsync<T>(string route, CancellationToken ct = default) =>
@@ -127,7 +190,13 @@ public sealed class AdminSession : IAsyncDisposable
public Task<T> SendSignedAsync<T>(HttpMethod method, string route, object? body, CancellationToken ct = default) =>
SendCoreAsync<T>(method, route, body is null ? null : LauncherApiClient.Serialize(body), signed: true, ct);
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct)
/// <summary>Requête qui modifie avec un corps binaire (morceau de fichier), signée par la clé.</summary>
public Task<T> SendSignedBytesAsync<T>(HttpMethod method, string route, byte[] body, string contentType, TimeSpan timeout,
CancellationToken ct = default) =>
SendCoreAsync<T>(method, route, body, signed: true, ct, contentType, timeout);
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null)
{
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
@@ -153,7 +222,7 @@ public sealed class AdminSession : IAsyncDisposable
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
}
}, ct).ConfigureAwait(false);
}, ct, contentType, timeout).ConfigureAwait(false);
}
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
{
@@ -17,5 +17,8 @@ public sealed class ApiException : Exception
/// <summary>Code stable (<see cref="ApiErrorCodes"/>).</summary>
public string Code { get; }
/// <summary>Délai annoncé par le serveur avant un nouvel essai (en-tête Retry-After), s'il y en a un.</summary>
public TimeSpan? RetryAfter { get; init; }
public bool IsUnauthorized => Status == 401;
}
+44 -15
View File
@@ -41,7 +41,8 @@ public sealed class LauncherApiClient : IDisposable
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
}, disposeHandler: handler is null)
{
Timeout = RequestTimeout
// Délai propre à chaque requête (SendAsync) : 30 s par défaut, plus pour l'envoi de fichiers.
Timeout = Timeout.InfiniteTimeSpan
};
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -129,33 +130,50 @@ public sealed class LauncherApiClient : IDisposable
public static readonly TimeSpan MaxClockOffset = TimeSpan.FromHours(1);
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
Action<HttpRequestMessage>? prepare, CancellationToken ct)
Action<HttpRequestMessage>? prepare, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null)
{
using var request = new HttpRequestMessage(method, Resolve(route));
if (body is not null)
{
request.Content = new ByteArrayContent(body);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
request.Content.Headers.ContentType = contentType == "application/json"
? new MediaTypeHeaderValue(contentType) { CharSet = "utf-8" }
: new MediaTypeHeaderValue(contentType);
}
prepare?.Invoke(request);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(timeout ?? RequestTimeout);
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
throw new ApiException(0, "unreachable", $"Serveur d'administration injoignable ({ex.Message}).", ex);
}
catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
}
using (response)
{
var bytes = await ReadBoundedAsync(response, ct).ConfigureAwait(false);
byte[] bytes;
try
{
bytes = await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
throw new ApiException(0, "timeout", "Le serveur d'administration ne répond pas.", ex);
}
catch (HttpRequestException ex)
{
throw new ApiException(0, "unreachable", $"Connexion au serveur d'administration interrompue ({ex.Message}).", ex);
}
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
try
@@ -191,25 +209,36 @@ public sealed class LauncherApiClient : IDisposable
private static ApiException ToException(HttpResponseMessage response, byte[] body)
{
var status = (int)response.StatusCode;
var retryAfter = RetryAfterOf(response);
try
{
if (body.Length > 0 && JsonSerializer.Deserialize<ApiError>(body, ApiJson.Options) is { Message.Length: > 0 } error)
return new ApiException(status, error.Code, error.Message);
return new ApiException(status, error.Code, error.Message) { RetryAfter = retryAfter };
}
catch (JsonException)
{
// réponse d'erreur non JSON (nginx, proxy…)
}
return status switch
var (code, message) = status switch
{
401 => new ApiException(status, ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
403 => new ApiException(status, ApiErrorCodes.Forbidden, "Action non autorisée."),
404 => new ApiException(status, ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
413 => new ApiException(status, ApiErrorCodes.InvalidRequest, "Requête trop volumineuse."),
429 => new ApiException(status, ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
>= 500 => new ApiException(status, ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
_ => new ApiException(status, ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
401 => (ApiErrorCodes.Unauthorized, "Session expirée : reconnectez-vous."),
403 => (ApiErrorCodes.Forbidden, "Action non autorisée."),
404 => (ApiErrorCodes.NotFound, "Adresse inconnue sur le serveur (version de l'API ?)."),
413 => (ApiErrorCodes.InvalidRequest,
"Requête trop volumineuse pour le serveur (envoi de fichiers : vérifiez nginx-publications.conf sur le VPS)."),
429 => (ApiErrorCodes.RateLimited, "Trop de requêtes : patientez une minute."),
>= 500 => (ApiErrorCodes.Error, $"Erreur du serveur ({status})."),
_ => (ApiErrorCodes.Error, $"Réponse inattendue du serveur ({status}).")
};
return new ApiException(status, code, message) { RetryAfter = retryAfter };
}
/// <summary>En-tête Retry-After (en secondes ou en date) ; absent ou incohérent : null.</summary>
private static TimeSpan? RetryAfterOf(HttpResponseMessage response)
{
var header = response.Headers.RetryAfter;
var delay = header?.Delta ?? (header?.Date is { } date ? date - DateTimeOffset.UtcNow : null);
return delay is { } value && value >= TimeSpan.Zero ? value : null;
}
/// <summary>Le serveur doit annoncer la même origine que celle configurée (sinon les signatures seraient refusées).</summary>
+29
View File
@@ -39,4 +39,33 @@ public static class ApiRoutes
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
public const string Catalog = "v1/catalog";
// ------------------------------------------------------------------ Notes de version (manifest.json de chaque jeu)
public const string GameChangelogPattern = "v1/games/{gameId}/changelog";
public const string GameChangelogEntryPattern = "v1/games/{gameId}/changelog/{version}";
/// <summary>Notes de version d'un jeu (identifiant et version : caractères sûrs uniquement, rien à encoder).</summary>
public static string GameChangelog(string gameId) => $"v1/games/{gameId}/changelog";
public static string GameChangelogEntry(string gameId, string version) => $"v1/games/{gameId}/changelog/{version}";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
public const string PublicationManifestPattern = "v1/publications/{id}/manifest";
public const string PublicationArchivePattern = "v1/publications/{id}/archive";
public const string PublicationObjectsPattern = "v1/publications/{id}/objects";
public const string PublicationObjectPattern = "v1/publications/{id}/objects/{sha256}";
public const string PublicationCommitPattern = "v1/publications/{id}/commit";
/// <summary>Préfixe des routes d'envoi (limite de taille propre, voir nginx-publications.conf).</summary>
public const string PublicationsPrefix = "v1/publications/";
public static string PublicationStart(string gameId) => $"v1/games/{gameId}/publications";
public static string Publication(string id) => $"v1/publications/{id}";
public static string PublicationManifest(string id, long offset) => $"v1/publications/{id}/manifest?offset={offset}";
public static string PublicationArchive(string id, long offset) => $"v1/publications/{id}/archive?offset={offset}";
public static string PublicationObjects(string id) => $"v1/publications/{id}/objects";
public static string PublicationObject(string id, string sha256, long offset) => $"v1/publications/{id}/objects/{sha256}?offset={offset}";
public static string PublicationCommit(string id) => $"v1/publications/{id}/commit";
}
@@ -1,11 +1,14 @@
using System.Globalization;
using System.Text;
using System.Text.Json.Serialization;
using GameLauncher.Core.Utils;
using System.Text.RegularExpressions;
namespace GameLauncher.Core.Models;
namespace GameLauncher.Contracts.Changelog;
/// <summary>
/// Notes d'une version du jeu (manifest.json → "changelog").
/// Partagé par le launcher (affichage), ManifestBuilder (notes.txt), le menu d'administration
/// (éditeur) et la Launcher API (contrôle avant d'écrire le manifest) : mêmes champs, mêmes bornes.
///
/// Format enrichi (launcher 1.2+) : titre, sous-titre, introduction, puis des sections
/// (« PROGRESSION », « ÉNIGMES »…) contenant des modifications, des commentaires,
@@ -31,8 +34,10 @@ public sealed class ChangelogEntry
/// <summary>Format historique : une modification par ligne (compatibilité avec les anciens launchers).</summary>
public List<string> Changes { get; set; } = new();
private static readonly CultureInfo French = CultureInfo.GetCultureInfo("fr-FR");
[JsonIgnore]
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", Format.Culture);
public string DateText => Date == default ? "" : Date.ToLocalTime().ToString("d MMMM yyyy", French);
[JsonIgnore]
public bool HasSubtitle => !string.IsNullOrWhiteSpace(Subtitle);
@@ -142,6 +147,69 @@ public static class ChangelogLimits
public const int MaxTitleLength = 200;
public const int MaxTagLength = 24;
public const int MaxTextLength = 2000;
public const int MaxVersionLength = 64;
/// <summary>
/// Taille maximale d'une modification envoyée à la Launcher API (UNE version à la fois) : sous la
/// limite de 64 Ko des requêtes (Limits:MaxRequestBodyBytes et nginx), qui n'est pas à augmenter.
/// </summary>
public const int MaxEditRequestBytes = 60 * 1024;
private static readonly Regex VersionRegex = new(@"\A[A-Za-z0-9._\-]{1,64}\z", RegexOptions.CultureInvariant);
/// <summary>Numéro de version modifiable en ligne (lettres, chiffres, « . », « _ », « - ») : il figure dans l'adresse de l'API.</summary>
public static bool IsEditableVersion(string? version) => version is not null && VersionRegex.IsMatch(version);
/// <summary>
/// Limites dépassées par une version, sans rien tronquer (contrairement à <see cref="Normalize"/>) :
/// messages affichés par l'éditeur du menu d'administration, refus (400) par le serveur.
/// </summary>
public static List<string> Check(ChangelogEntry entry)
{
var errors = new List<string>();
void Length(string? value, int max, string what)
{
if (value is not null && value.Trim().Length > max) errors.Add($"{what} : {max} caractères au plus.");
}
Length(entry.Title, MaxTitleLength, "Titre");
Length(entry.Subtitle, MaxTitleLength * 2, "Sous-titre");
var introduction = (entry.Introduction ?? new List<string>()).OfType<string>().ToList();
if (introduction.Count > MaxIntroductionParagraphs)
errors.Add($"Introduction : {MaxIntroductionParagraphs} paragraphes au plus.");
foreach (var paragraph in introduction) Length(paragraph, MaxTextLength, "Introduction (paragraphe)");
var sections = (entry.Sections ?? new List<ChangelogSection>()).OfType<ChangelogSection>().ToList();
if (sections.Count > MaxSectionsPerEntry) errors.Add($"{MaxSectionsPerEntry} sections au plus.");
var items = 0;
for (var s = 0; s < sections.Count; s++)
{
var section = sections[s];
var where = $"Section {s + 1}";
Length(section.Title, MaxTitleLength, $"{where} (titre)");
var sectionItems = (section.Items ?? new List<ChangelogItem>()).OfType<ChangelogItem>().ToList();
if (sectionItems.Count > MaxItemsPerSection) errors.Add($"{where} : {MaxItemsPerSection} éléments au plus.");
items += sectionItems.Count;
for (var i = 0; i < sectionItems.Count; i++)
{
var item = sectionItems[i];
var at = $"{where}, élément {i + 1}";
Length(item.Text, MaxTextLength, at);
Length(item.Tag, MaxTagLength, $"{at} (étiquette)");
Length(item.Note, MaxTextLength, $"{at} (note)");
var details = (item.Details ?? new List<string>()).OfType<string>().ToList();
if (details.Count > MaxDetailsPerItem) errors.Add($"{at} : {MaxDetailsPerItem} sous-points au plus.");
foreach (var detail in details) Length(detail, MaxTextLength, $"{at} (sous-point)");
}
}
if (items > MaxItemsPerEntry) errors.Add($"{MaxItemsPerEntry} éléments au plus par version ({items} actuellement).");
if (sections.Count == 0 && (entry.Changes?.Count ?? 0) > MaxFlatChanges)
errors.Add($"{MaxFlatChanges} modifications au plus.");
return errors.Distinct().ToList();
}
/// <summary>Nombre d'éléments affichés (budget <see cref="MaxTotalItems"/> de tout l'historique).</summary>
public static int DisplayedItems(ChangelogEntry entry) => entry.DisplaySections.Sum(s => s.Items.Count);
public static List<ChangelogEntry> Normalize(IEnumerable<ChangelogEntry?>? entries)
{
@@ -229,7 +297,8 @@ public static class ChangelogLimits
var text = sb.ToString().Trim();
if (text.Length == 0) return null;
if (text.Length > maxLength) text = text[..maxLength].TrimEnd() + "…";
// « … » compris dans la longueur maximale : un texte tronqué reste dans les limites (Check).
if (text.Length > maxLength) text = text[..Math.Max(0, maxLength - 1)].TrimEnd() + "…";
return text;
}
@@ -0,0 +1,34 @@
namespace GameLauncher.Contracts.Changelog;
/// <summary>
/// Notes de version d'un jeu telles que publiées dans son manifest.json (sur le serveur), pour
/// l'éditeur du menu d'administration.
/// </summary>
public sealed record ChangelogState
{
public string GameId { get; init; } = "";
/// <summary>Version du jeu actuellement publiée (champ « version » du manifest).</summary>
public string ManifestVersion { get; init; } = "";
/// <summary>
/// Empreinte SHA-256 du manifest lu, à renvoyer avec une modification : si le manifest a changé
/// entre-temps (nouvelle version publiée, autre administrateur), la modification est refusée (409).
/// </summary>
public string ManifestSha256 { get; init; } = "";
/// <summary>Notes de chaque version, de la plus récente à la plus ancienne (bornées par <see cref="ChangelogLimits"/>).</summary>
public List<ChangelogEntry> Entries { get; init; } = new();
}
/// <summary>
/// Modification des notes d'UNE version existante. Le serveur ne change que cette entrée du manifest
/// (numéro et date de version conservés) : fichiers, archive, version et exécutable restent identiques.
/// </summary>
public sealed record ChangelogUpdateRequest
{
/// <summary>Empreinte du manifest sur lequel la modification a été préparée (<see cref="ChangelogState.ManifestSha256"/>).</summary>
public string BaseSha256 { get; init; } = "";
public ChangelogEntry? Entry { get; init; }
}
@@ -59,4 +59,13 @@ public static class ApiErrorCodes
/// <summary>Fonction non configurée sur le serveur.</summary>
public const string NotConfigured = "not_configured";
/// <summary>Envoi d'un morceau à une position qui n'est pas la fin de ce que le serveur a reçu.</summary>
public const string OffsetMismatch = "offset_mismatch";
/// <summary>Une publication de ce jeu est en cours (autre administrateur).</summary>
public const string PublicationInProgress = "publication_in_progress";
/// <summary>Espace disque insuffisant sur le serveur.</summary>
public const string InsufficientStorage = "insufficient_storage";
}
@@ -0,0 +1,207 @@
using System.Text.Json;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Publishing;
/// <summary>Ce que contient un manifest.json contrôlé.</summary>
/// <param name="Objects">Objets à servir (empreinte en minuscules → taille), une fois chacun.</param>
public sealed record ManifestSummary(
string GameId,
string Version,
string Executable,
string ArchiveName,
long ArchiveSize,
string ArchiveSha256,
IReadOnlyDictionary<string, long> Objects,
int FileCount,
long TotalSize,
string? NotesTitle);
/// <summary>
/// Contrôle d'un manifest.json produit par ManifestBuilder, AVANT de le publier : par le launcher
/// (dossier choisi) et par le serveur (manifest reçu). Mêmes règles des deux côtés ; le serveur ne fait
/// confiance à rien de ce qu'il reçoit.
/// • identifiant de jeu et version attendus ;
/// • archive : simple nom de fichier .zip (jamais un chemin), taille et empreinte valides ;
/// • objets dans « objects/ » (seul dossier écrit par la publication) ;
/// • chemins des fichiers relatifs et sûrs (ni « .. », ni chemin absolu, ni « : »), sans doublon ;
/// • exécutable présent parmi les fichiers ; tailles et nombre de fichiers bornés.
/// </summary>
public static partial class ManifestCheck
{
[GeneratedRegex(@"\A[A-Za-z0-9._+\-]{1,64}\z")]
private static partial Regex VersionRegex();
[GeneratedRegex(@"\A[A-Za-z0-9][A-Za-z0-9._+\-]{0,127}\.zip\z", RegexOptions.IgnoreCase)]
private static partial Regex ArchiveNameRegex();
private static readonly JsonDocumentOptions ReadOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 64
};
public static bool IsValidVersion(string? version) => version is not null && VersionRegex().IsMatch(version);
/// <summary>Contrôle ; null (et <paramref name="errors"/> rempli) si le manifest n'est pas publiable.</summary>
public static ManifestSummary? Check(ReadOnlySpan<byte> json, string expectedGameId, string? expectedVersion, List<string> errors)
{
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
if (json.Length > PublishingLimits.MaxManifestBytes)
{
errors.Add("Manifest trop volumineux.");
return null;
}
JsonDocument document;
try
{
document = JsonDocument.Parse(json.ToArray(), ReadOptions);
}
catch (JsonException ex)
{
errors.Add($"Manifest illisible : {ex.Message}");
return null;
}
using (document)
{
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
errors.Add("Manifest illisible : objet JSON attendu.");
return null;
}
if (Get(root, "formatVersion") is { } format && (format.ValueKind != JsonValueKind.Number || format.GetDouble() != 1))
errors.Add("Format de manifest non pris en charge (formatVersion 1 attendu).");
var gameId = Text(root, "gameId");
if (!string.Equals(gameId, expectedGameId, StringComparison.OrdinalIgnoreCase))
errors.Add($"Ce manifest est celui du jeu « {gameId} », pas de « {expectedGameId} ».");
var version = Text(root, "version");
if (!IsValidVersion(version))
errors.Add($"Version « {version} » invalide (lettres, chiffres, « . », « _ », « + », « - »).");
else if (expectedVersion is not null && !string.Equals(version, expectedVersion, StringComparison.Ordinal))
errors.Add($"Le manifest est celui de la version {version}, pas {expectedVersion}.");
var objectsBase = Text(root, "objectsBaseUrl");
if (!string.IsNullOrEmpty(objectsBase) && objectsBase is not ("objects/" or "objects"))
errors.Add($"« objectsBaseUrl » doit être « objects/ » (manifest produit par ManifestBuilder), pas « {objectsBase} ».");
// Archive (installation initiale)
string archiveName = "", archiveSha = "";
long archiveSize = 0;
if (Get(root, "archive") is { ValueKind: JsonValueKind.Object } archive)
{
archiveName = Text(archive, "url") ?? "";
archiveSha = (Text(archive, "sha256") ?? "").ToLowerInvariant();
archiveSize = Number(archive, "size");
if (!ArchiveNameRegex().IsMatch(archiveName))
errors.Add($"Archive « {archiveName} » : simple nom de fichier .zip attendu (à côté du manifest).");
if (!PublishingLimits.IsValidSha256(archiveSha)) errors.Add("Archive : empreinte SHA-256 invalide.");
if (archiveSize <= 0 || archiveSize > PublishingLimits.MaxArchiveBytes) errors.Add("Archive : taille invalide.");
}
else
{
errors.Add("Archive absente du manifest.");
}
// Fichiers du jeu
var objects = new Dictionary<string, long>(StringComparer.Ordinal);
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
long totalSize = 0;
var fileCount = 0;
if (Get(root, "files") is { ValueKind: JsonValueKind.Array } files)
{
fileCount = files.GetArrayLength();
if (fileCount == 0) errors.Add("Aucun fichier dans le manifest.");
if (fileCount > PublishingLimits.MaxFiles) errors.Add($"Trop de fichiers ({fileCount} ; {PublishingLimits.MaxFiles} au plus).");
else
{
var index = 0;
foreach (var file in files.EnumerateArray())
{
index++;
if (errors.Count > 20) break;
if (file.ValueKind != JsonValueKind.Object)
{
errors.Add($"Fichier n° {index} invalide.");
continue;
}
var path = Text(file, "path") ?? "";
var sha = (Text(file, "sha256") ?? "").ToLowerInvariant();
var size = Number(file, "size");
if (!IsSafeRelativePath(path)) errors.Add($"Chemin non autorisé : « {path} ».");
else if (!paths.Add(path.Replace('\\', '/'))) errors.Add($"Fichier en double : « {path} ».");
if (!PublishingLimits.IsValidSha256(sha)) errors.Add($"« {path} » : empreinte SHA-256 invalide.");
else if (size < 0 || size > PublishingLimits.MaxObjectBytes) errors.Add($"« {path} » : taille invalide.");
else if (objects.TryGetValue(sha, out var known) && known != size)
errors.Add($"« {path} » : même empreinte qu'un autre fichier mais taille différente.");
else
{
objects[sha] = size;
totalSize += size;
}
}
}
}
else
{
errors.Add("Liste des fichiers absente du manifest.");
}
var executable = (Text(root, "executable") ?? "").Replace('\\', '/');
if (string.IsNullOrWhiteSpace(executable)) errors.Add("Exécutable absent du manifest.");
else if (!paths.Contains(executable)) errors.Add($"L'exécutable « {executable} » ne fait pas partie des fichiers.");
if (Get(root, "changelog") is { } changelog && changelog.ValueKind is not (JsonValueKind.Array or JsonValueKind.Null))
errors.Add("Notes de version (« changelog ») : liste attendue.");
if (errors.Count > 0) return null;
return new ManifestSummary(gameId!, version!, executable, archiveName, archiveSize, archiveSha, objects, fileCount, totalSize,
NotesTitle(root, version!));
}
}
/// <summary>Chemin relatif sûr (mêmes règles que le launcher pour l'installation).</summary>
public static bool IsSafeRelativePath(string path)
{
if (string.IsNullOrWhiteSpace(path) || path.Length > 1024 || path.Any(char.IsControl)) return false;
var p = path.Replace('\\', '/');
if (p.StartsWith('/') || p.Contains(':')) return false;
return p.Split('/').All(s => s.Length > 0 && s != "." && s != "..");
}
/// <summary>Chemin d'un objet sous le dossier du manifest : « objects/ab/abcdef….bin ».</summary>
public static string ObjectPath(string sha256)
{
var h = sha256.ToLowerInvariant();
return $"objects/{h[..2]}/{h}.bin";
}
private static string? NotesTitle(JsonElement root, string version)
{
if (Get(root, "changelog") is not { ValueKind: JsonValueKind.Array } changelog) return null;
foreach (var entry in changelog.EnumerateArray())
if (entry.ValueKind == JsonValueKind.Object && Text(entry, "version") == version)
return Text(entry, "title");
return null;
}
private static JsonElement? Get(JsonElement element, string name)
{
foreach (var property in element.EnumerateObject())
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
return property.Value;
return null;
}
private static string? Text(JsonElement element, string name) =>
Get(element, name) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null;
private static long Number(JsonElement element, string name) =>
Get(element, name) is { ValueKind: JsonValueKind.Number } value && value.TryGetInt64(out var number) ? number : -1;
}
@@ -0,0 +1,160 @@
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Publishing;
/// <summary>
/// Bornes de la publication d'une version depuis le menu d'administration. Les requêtes d'envoi
/// (morceaux de fichiers) sont les seules à dépasser 64 Ko : elles ont leur propre limite, côté
/// serveur et côté nginx (server/deploy/nginx-publications.conf).
/// </summary>
public static partial class PublishingLimits
{
/// <summary>Taille d'un morceau envoyé par le launcher.</summary>
public const int ChunkBytes = 4 * 1024 * 1024;
/// <summary>Taille maximale d'un corps de requête d'envoi accepté par le serveur (morceau ou lot d'objets).</summary>
public const long MaxUploadRequestBytes = 8L * 1024 * 1024 + 64 * 1024;
/// <summary>Objets de cette taille au plus : envoyés par lots ; au-delà, en morceaux.</summary>
public const int BatchObjectMaxBytes = 2 * 1024 * 1024;
public const int MaxObjectsPerBatch = 500;
public const int MaxFiles = 10_000;
public const long MaxManifestBytes = 64L * 1024 * 1024;
public const long MaxObjectBytes = 32L * 1024 * 1024 * 1024;
public const long MaxArchiveBytes = 128L * 1024 * 1024 * 1024;
/// <summary>Publication abandonnée (aucun envoi) au-delà de cette durée : supprimée par le serveur.</summary>
public static readonly TimeSpan IdleExpiry = TimeSpan.FromHours(48);
/// <summary>Publication d'un autre administrateur sans envoi depuis cette durée : peut être remplacée.</summary>
public static readonly TimeSpan TakeoverAfter = TimeSpan.FromMinutes(30);
/// <summary>Type du corps d'un lot d'objets (voir <see cref="ObjectBatch"/>).</summary>
public const string ObjectBatchContentType = "application/vnd.gamelauncher.objects";
public const string BinaryContentType = "application/octet-stream";
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
private static partial Regex PublicationIdRegex();
[GeneratedRegex(@"\A[0-9a-fA-F]{64}\z")]
private static partial Regex Sha256Regex();
public static bool IsValidPublicationId(string? id) => id is not null && PublicationIdRegex().IsMatch(id);
public static bool IsValidSha256(string? value) => value is not null && Sha256Regex().IsMatch(value);
}
/// <summary>Début (ou reprise) de la publication d'une version : le manifest est envoyé ensuite.</summary>
public sealed record PublicationStartRequest
{
public string Version { get; init; } = "";
/// <summary>Empreinte du manifest.json à publier : même manifest = reprise d'un envoi interrompu.</summary>
public string ManifestSha256 { get; init; } = "";
public long ManifestSize { get; init; }
}
/// <summary>État d'une publication en cours (ce qui reste à envoyer).</summary>
public sealed record PublicationState
{
public string PublicationId { get; init; } = "";
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
/// <summary>Version en ligne au début de la publication (vide : aucune).</summary>
public string PublishedVersion { get; init; } = "";
/// <summary>Vrai si un envoi interrompu de ce même manifest est repris.</summary>
public bool Resumed { get; init; }
public long ManifestSize { get; init; }
public long ManifestReceived { get; init; }
/// <summary>Manifest reçu et contrôlé : la liste des objets à envoyer est connue.</summary>
public bool ManifestAccepted { get; init; }
/// <summary>Fichiers (objets) du manifest ; ceux déjà présents sur le serveur ne sont pas renvoyés.</summary>
public int ObjectCount { get; init; }
public int ObjectsOnServer { get; init; }
/// <summary>Empreintes des objets restant à envoyer.</summary>
public List<string> PendingObjects { get; init; } = new();
/// <summary>Objets envoyés en partie (empreinte → octets reçus) : l'envoi reprend à cet endroit.</summary>
public Dictionary<string, long> PartialObjects { get; init; } = new();
public string? ArchiveName { get; init; }
public long ArchiveSize { get; init; }
public long ArchiveReceived { get; init; }
public bool ArchiveComplete { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>Réponse à l'envoi d'un morceau (ou d'un lot d'objets).</summary>
public sealed record ChunkResult
{
/// <summary>Octets reçus pour ce fichier (ou nombre d'objets enregistrés pour un lot).</summary>
public long Received { get; init; }
/// <summary>Fichier complet et vérifié (SHA-256).</summary>
public bool Complete { get; init; }
}
/// <summary>Version publiée.</summary>
public sealed record PublicationResult
{
public string GameId { get; init; } = "";
public string Version { get; init; } = "";
public string PreviousVersion { get; init; } = "";
public string ManifestSha256 { get; init; } = "";
public int ObjectsAdded { get; init; }
public bool ArchiveAdded { get; init; }
}
/// <summary>
/// Lot d'objets dans un seul corps de requête (petits fichiers : une requête pour des centaines de
/// fichiers) : pour chaque objet, empreinte SHA-256 (32 octets bruts), taille (8 octets, gros-boutiste)
/// puis contenu.
/// </summary>
public static class ObjectBatch
{
public const int HeaderBytes = 32 + 8;
public static byte[] Write(IReadOnlyList<(string Sha256, byte[] Data)> objects)
{
var total = objects.Sum(o => (long)HeaderBytes + o.Data.Length);
var buffer = new byte[total];
var position = 0;
foreach (var (sha, data) in objects)
{
Convert.FromHexString(sha).CopyTo(buffer, position);
System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(buffer.AsSpan(position + 32, 8), data.Length);
data.CopyTo(buffer, position + HeaderBytes);
position += HeaderBytes + data.Length;
}
return buffer;
}
/// <summary>Lecture bornée ; <see cref="InvalidDataException"/> si le lot est mal formé.</summary>
public static List<(string Sha256, ReadOnlyMemory<byte> Data)> Read(ReadOnlyMemory<byte> body, int maxObjects)
{
var result = new List<(string, ReadOnlyMemory<byte>)>();
var position = 0;
while (position < body.Length)
{
if (result.Count >= maxObjects) throw new InvalidDataException($"Lot trop grand ({maxObjects} objets au plus).");
if (body.Length - position < HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
var span = body.Span;
var sha = Convert.ToHexString(span.Slice(position, 32)).ToLowerInvariant();
var length = System.Buffers.Binary.BinaryPrimitives.ReadInt64BigEndian(span.Slice(position + 32, 8));
if (length < 0 || length > body.Length - position - HeaderBytes) throw new InvalidDataException("Lot d'objets tronqué.");
result.Add((sha, body.Slice(position + HeaderBytes, (int)length)));
position += HeaderBytes + (int)length;
}
return result;
}
}
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Models;
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
namespace GameLauncher.Core.SelfUpdate;
@@ -0,0 +1,61 @@
using System.Net;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Services;
/// <summary>
/// Historique des notes de version lors d'une publication (ManifestBuilder). Avec --remote-manifest,
/// l'historique est repris du manifest EN LIGNE : les corrections faites depuis le menu
/// d'administration ne sont pas écrasées par le manifest.json local (resté dans son état d'origine).
/// </summary>
public static class ChangelogHistory
{
/// <summary>
/// Manifest publié (null s'il n'existe pas encore : première publication). Toute autre erreur
/// (réseau, fichier illisible) est signalée : publier sans elle perdrait les corrections en ligne.
/// </summary>
public static async Task<GameManifest?> FetchPublishedAsync(HttpDownloader http, Uri url, CancellationToken ct = default)
{
if (url.Scheme != Uri.UriSchemeHttps && !(url.Scheme == Uri.UriSchemeHttp && url.IsLoopback))
throw new ArgumentException($"Adresse du manifest en ligne : https:// attendu ({url}).");
string json;
try
{
json = await http.GetStringAsync(url, ct).ConfigureAwait(false);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
try
{
return JsonSerializer.Deserialize<GameManifest>(json, JsonDefaults.Options)
?? throw new InvalidDataException("manifest vide");
}
catch (JsonException ex)
{
throw new InvalidDataException($"manifest illisible : {ex.Message}", ex);
}
}
/// <summary>
/// Notes de la version publiée en tête, puis l'historique (sans cette version si elle est republiée
/// avec de nouvelles notes), bornés comme par le launcher. Sans nouvelles notes (pas de --notes),
/// les notes existantes de la version, éventuellement corrigées en ligne, sont conservées.
/// </summary>
public static List<ChangelogEntry> Build(IEnumerable<ChangelogEntry?>? history, ChangelogEntry entry)
{
var list = (history ?? Enumerable.Empty<ChangelogEntry?>()).OfType<ChangelogEntry>().ToList();
if (!entry.IsEmpty)
{
list.RemoveAll(c => string.Equals(c.Version, entry.Version, StringComparison.Ordinal));
list.Insert(0, entry);
}
return ChangelogLimits.Normalize(list);
}
}
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
@@ -1,4 +1,5 @@
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
@@ -0,0 +1,25 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Changelog;
/// <summary>
/// Module « Notes de version » : page du menu d'administration pour corriger les notes des versions
/// déjà publiées (manifest.json du serveur). Permission changelog.edit.
/// </summary>
public sealed class ChangelogAdminModule : ILauncherModule
{
public string Id => "changelog";
public string DisplayName => "Notes de version";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
ChangelogPanelViewModel.PanelId,
"Notes de version",
"Corrigez les notes des versions déjà publiées : texte, sections, étiquettes, sous-points. " +
"Une nouvelle version et ses notes se publient toujours avec ManifestBuilder (notes.txt).",
"FileDocument",
AdminPermissions.ChangelogEdit,
Order: 20,
context => new ChangelogPanelViewModel(context)));
}
@@ -0,0 +1,401 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Changelog;
/// <summary>
/// Notes d'une version, en cours de modification : en-tête (titre, sous-titre, introduction), puis
/// sections › éléments. <see cref="Preview"/> est l'entrée telle que le launcher l'affichera (mêmes
/// bornes, mêmes modèles d'affichage), recalculée à chaque modification.
/// </summary>
public sealed class VersionEditor : ObservableObject
{
private readonly Action _changed;
private readonly Func<string, string, bool> _confirm;
private string _title = "", _subtitle = "", _introduction = "";
private string _snapshot = "";
private bool _isDirty, _showPreview, _loading;
private ChangelogEntry _preview;
private IReadOnlyList<string> _limitErrors = Array.Empty<string>();
/// <param name="confirm">Confirmation (titre, message) avant de retirer une section qui contient des éléments.</param>
/// <param name="changed">Prévient la page (bouton « Enregistrer », pastilles).</param>
public VersionEditor(ChangelogEntry published, Func<string, string, bool> confirm, Action changed)
{
Version = published.Version;
Date = published.Date;
_confirm = confirm;
_changed = changed;
_preview = new ChangelogEntry { Version = Version, Date = Date };
Sections.CollectionChanged += OnSectionsChanged;
AddSectionCommand = new RelayCommand(AddSection, () => Sections.Count < ChangelogLimits.MaxSectionsPerEntry);
Load(published);
}
public string Version { get; }
public DateTimeOffset Date { get; }
public string DateText => Date == default ? "" : _preview.DateText;
// ------------------------------------------------------------------ En-tête
public string Title { get => _title; set => Set(ref _title, value ?? ""); }
/// <summary>Sous-titre : une ou plusieurs lignes.</summary>
public string Subtitle { get => _subtitle; set => Set(ref _subtitle, value ?? ""); }
/// <summary>Paragraphes d'introduction, séparés par une ligne vide.</summary>
public string Introduction { get => _introduction; set => Set(ref _introduction, value ?? ""); }
public ObservableCollection<SectionEditor> Sections { get; } = new();
public RelayCommand AddSectionCommand { get; }
// ------------------------------------------------------------------ État
/// <summary>Modifié depuis le chargement (ou le dernier enregistrement).</summary>
public bool IsDirty
{
get => _isDirty;
private set => SetProperty(ref _isDirty, value);
}
/// <summary>Affichage : modification (faux) ou aperçu tel que le verront les joueurs (vrai).</summary>
public bool ShowPreview
{
get => _showPreview;
set
{
if (!SetProperty(ref _showPreview, value)) return;
OnPropertyChanged(nameof(IsEditMode));
OnPropertyChanged(nameof(IsPreviewMode));
}
}
public bool IsEditMode { get => !_showPreview; set { if (value) ShowPreview = false; } }
public bool IsPreviewMode { get => _showPreview; set { if (value) ShowPreview = true; } }
/// <summary>Notes telles que le launcher les affichera.</summary>
public ChangelogEntry Preview
{
get => _preview;
private set => SetProperty(ref _preview, value);
}
/// <summary>Limites dépassées (rien n'est tronqué : à corriger avant d'enregistrer).</summary>
public IReadOnlyList<string> LimitErrors
{
get => _limitErrors;
private set
{
_limitErrors = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasLimitErrors));
}
}
public bool HasLimitErrors => _limitErrors.Count > 0;
/// <summary>« 3 sections · 14 éléments ».</summary>
public string CountText
{
get
{
var items = Sections.Sum(s => s.Items.Count);
return $"{Sections.Count} section{(Sections.Count > 1 ? "s" : "")} · {items} élément{(items > 1 ? "s" : "")} " +
$"(au plus {ChangelogLimits.MaxItemsPerEntry})";
}
}
// ------------------------------------------------------------------ Chargement, conversion
/// <summary>Affiche des notes publiées et en fait la référence « sans modification ».</summary>
public void Load(ChangelogEntry entry)
{
_loading = true;
try
{
_title = entry.Title ?? "";
_subtitle = entry.Subtitle ?? "";
_introduction = string.Join("\n\n", entry.Introduction ?? new List<string>());
Sections.Clear();
// Ancien format (liste à plat) : présenté en section(s) sans titre, enregistré au format enrichi.
var sections = entry.Sections.Count > 0
? entry.Sections
: (entry.Changes ?? new List<string>())
.Chunk(ChangelogLimits.MaxItemsPerSection)
.Select(chunk => new ChangelogSection { Items = chunk.Select(c => new ChangelogItem { Text = c }).ToList() })
.ToList();
foreach (var section in sections) Sections.Add(new SectionEditor(section, this));
foreach (var name in new[] { nameof(Title), nameof(Subtitle), nameof(Introduction) }) OnPropertyChanged(name);
}
finally
{
_loading = false;
}
_snapshot = Snapshot(Normalized(ToEntry()));
Refresh();
}
/// <summary>Notes saisies (non tronquées) : c'est ce qui est envoyé au serveur.</summary>
public ChangelogEntry ToEntry()
{
var entry = new ChangelogEntry
{
Version = Version,
Date = Date,
Title = Clean(Title),
Subtitle = Clean(Subtitle.Replace("\r\n", "\n")),
Introduction = Paragraphs(Introduction),
Sections = Sections.Select(s => s.ToSection()).ToList()
};
// Liste à plat des anciens launchers : régénérée par le serveur à l'enregistrement (requête allégée).
if (entry.Sections.Count == 0) entry.Changes = new List<string>();
return entry;
}
// ------------------------------------------------------------------ Modifications
internal void Changed()
{
if (_loading) return;
Refresh();
}
private void Refresh()
{
var entry = ToEntry();
var preview = Normalized(entry);
var errors = ChangelogLimits.Check(entry);
if (preview is null) errors.Insert(0, "Les notes ne peuvent pas être vides : gardez au moins un titre ou un élément.");
LimitErrors = errors;
Preview = preview ?? new ChangelogEntry { Version = Version, Date = Date };
IsDirty = Snapshot(preview) != _snapshot;
OnPropertyChanged(nameof(CountText));
AddSectionCommand.RaiseCanExecuteChanged();
_changed();
}
private bool Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
{
if (!SetProperty(ref field, value, name)) return false;
Changed();
return true;
}
private void AddSection()
{
if (Sections.Count >= ChangelogLimits.MaxSectionsPerEntry) return;
var section = new SectionEditor(new ChangelogSection(), this);
section.AddChangeCommand.Execute(null);
Sections.Add(section);
}
internal void MoveSection(SectionEditor section, int delta) => ListMoves.Move(Sections, section, delta);
internal void RemoveSection(SectionEditor section)
{
if (section.Items.Any(i => !i.IsEmpty) &&
!_confirm("Retirer la section ?", $"La section « {(string.IsNullOrWhiteSpace(section.Title) ? "sans titre" : section.Title.Trim())} » " +
$"et ses {section.Items.Count} élément(s) seront retirés de ces notes (rien n'est enregistré avant « Enregistrer »)."))
return;
Sections.Remove(section);
}
private void OnSectionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
foreach (var section in Sections) section.RefreshMoves();
Changed();
}
// ------------------------------------------------------------------ Outils
private static ChangelogEntry? Normalized(ChangelogEntry entry) => ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault();
private static string Snapshot(ChangelogEntry? entry) => entry is null ? "" : JsonSerializer.Serialize(entry, ApiJson.Options);
internal static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
/// <summary>Paragraphes séparés par une (ou plusieurs) ligne(s) vide(s) ; retours à la ligne simples gardés en espaces.</summary>
private static List<string> Paragraphs(string text) =>
System.Text.RegularExpressions.Regex.Split(text.Replace("\r\n", "\n"), @"\n[ \t]*\n")
.Select(p => Clean(p.Replace('\n', ' ')))
.OfType<string>()
.ToList();
/// <summary>Une ligne par élément (sous-points).</summary>
internal static List<string> Lines(string text) =>
text.Replace("\r\n", "\n").Split('\n').Select(Clean).OfType<string>().ToList();
}
/// <summary>Section des notes (titre facultatif) et ses éléments.</summary>
public sealed class SectionEditor : ObservableObject
{
private readonly VersionEditor _version;
private string _title;
public SectionEditor(ChangelogSection section, VersionEditor version)
{
_version = version;
_title = section.Title ?? "";
AddChangeCommand = new RelayCommand(() => Add(comment: false), CanAdd);
AddCommentCommand = new RelayCommand(() => Add(comment: true), CanAdd);
foreach (var item in section.Items ?? new List<ChangelogItem>()) Items.Add(new ItemEditor(item, this));
Items.CollectionChanged += (_, _) =>
{
foreach (var item in Items) item.RefreshMoves();
OnPropertyChanged(nameof(CountText));
AddChangeCommand.RaiseCanExecuteChanged();
AddCommentCommand.RaiseCanExecuteChanged();
_version.Changed();
};
MoveUpCommand = new RelayCommand(() => _version.MoveSection(this, -1), () => _version.Sections.IndexOf(this) > 0);
MoveDownCommand = new RelayCommand(() => _version.MoveSection(this, +1),
() => _version.Sections.IndexOf(this) is var i && i >= 0 && i < _version.Sections.Count - 1);
RemoveCommand = new RelayCommand(() => _version.RemoveSection(this));
}
/// <summary>Titre de la section (facultatif), ex. « PROGRESSION ».</summary>
public string Title
{
get => _title;
set
{
if (SetProperty(ref _title, value ?? "")) _version.Changed();
}
}
public ObservableCollection<ItemEditor> Items { get; } = new();
public string CountText => $"{Items.Count} élément{(Items.Count > 1 ? "s" : "")}";
public RelayCommand AddChangeCommand { get; }
public RelayCommand AddCommentCommand { get; }
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
internal VersionEditor Version => _version;
internal ChangelogSection ToSection() => new()
{
Title = VersionEditor.Clean(Title),
Items = Items.Select(i => i.ToItem()).ToList()
};
internal void RefreshMoves()
{
MoveUpCommand.RaiseCanExecuteChanged();
MoveDownCommand.RaiseCanExecuteChanged();
}
internal void MoveItem(ItemEditor item, int delta) => ListMoves.Move(Items, item, delta);
internal void RemoveItem(ItemEditor item) => Items.Remove(item);
private bool CanAdd() => Items.Count < ChangelogLimits.MaxItemsPerSection;
private void Add(bool comment)
{
if (!CanAdd()) return;
Items.Add(new ItemEditor(new ChangelogItem { Type = comment ? ChangelogItem.CommentType : null }, this));
}
}
/// <summary>Élément d'une section : une modification (étiquette, sous-points, note) ou un commentaire.</summary>
public sealed class ItemEditor : ObservableObject
{
public static readonly IReadOnlyList<Choice> Kinds = new[]
{
new Choice(ChangelogItem.ChangeType, "Modification"),
new Choice(ChangelogItem.CommentType, "Commentaire")
};
private readonly SectionEditor _section;
private Choice _kind;
private string _text, _tag, _details, _note;
public ItemEditor(ChangelogItem item, SectionEditor section)
{
_section = section;
_kind = item.IsComment ? Kinds[1] : Kinds[0];
_text = item.Text ?? "";
_tag = item.Tag ?? "";
_details = string.Join("\n", item.Details ?? new List<string>());
_note = item.Note ?? "";
MoveUpCommand = new RelayCommand(() => _section.MoveItem(this, -1), () => _section.Items.IndexOf(this) > 0);
MoveDownCommand = new RelayCommand(() => _section.MoveItem(this, +1),
() => _section.Items.IndexOf(this) is var i && i >= 0 && i < _section.Items.Count - 1);
RemoveCommand = new RelayCommand(() => _section.RemoveItem(this));
}
public IReadOnlyList<Choice> KindChoices => Kinds;
public Choice Kind
{
get => _kind;
set
{
// null : liste de l'écran en cours de remplacement, pas un choix de l'administrateur.
if (value is null || !SetProperty(ref _kind, value)) return;
OnPropertyChanged(nameof(IsComment));
OnPropertyChanged(nameof(IsChange));
_section.Version.Changed();
}
}
public bool IsComment => _kind.Value == ChangelogItem.CommentType;
public bool IsChange => !IsComment;
public string Text { get => _text; set => Set(ref _text, value ?? ""); }
/// <summary>Étiquette en pastille, ex. « NOUVEAU » (modifications uniquement).</summary>
public string Tag { get => _tag; set => Set(ref _tag, value ?? ""); }
/// <summary>Sous-points : un par ligne (modifications uniquement).</summary>
public string Details { get => _details; set => Set(ref _details, value ?? ""); }
/// <summary>Texte de conclusion après les sous-points (modifications uniquement).</summary>
public string Note { get => _note; set => Set(ref _note, value ?? ""); }
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
internal bool IsEmpty => string.IsNullOrWhiteSpace(Text) && string.IsNullOrWhiteSpace(Details);
internal ChangelogItem ToItem() => IsComment
? new ChangelogItem { Type = ChangelogItem.CommentType, Text = Text.Trim() }
: new ChangelogItem
{
Text = Text.Trim(),
Tag = VersionEditor.Clean(Tag),
Details = VersionEditor.Lines(Details),
Note = VersionEditor.Clean(Note)
};
internal void RefreshMoves()
{
MoveUpCommand.RaiseCanExecuteChanged();
MoveDownCommand.RaiseCanExecuteChanged();
}
private void Set(ref string field, string value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
{
if (SetProperty(ref field, value, name)) _section.Version.Changed();
}
}
internal static class ListMoves
{
public static void Move<T>(ObservableCollection<T> list, T item, int delta)
{
var index = list.IndexOf(item);
var target = index + delta;
if (index < 0 || target < 0 || target >= list.Count) return;
list.Move(index, target);
}
}
@@ -0,0 +1,243 @@
using System.Collections.ObjectModel;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Changelog;
/// <summary>
/// Page « Notes de version » : modification des notes des versions DÉJÀ publiées, dans le manifest.json
/// du serveur (la création d'une version reste l'affaire de ManifestBuilder et du .bat).
/// • liste des versions (pastille « modifié »), éditeur en-tête › sections › éléments, aperçu en
/// direct avec les modèles d'affichage du launcher (Themes/Changelog.xaml) ;
/// • mêmes bornes que le launcher, vérifiées avant l'envoi (rien n'est tronqué en silence) ;
/// • « Enregistrer » : une requête signée par version modifiée, avec l'empreinte du manifest lu
/// (si le manifest a changé entre-temps, rien n'est écrasé et le rechargement est proposé).
/// </summary>
public sealed class ChangelogPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "changelog.notes";
private ChangelogState? _state;
private Choice? _selectedGame;
private VersionEditor? _selectedVersion;
private bool _hasChanges, _hasConflict;
private string? _statusMessage;
private int _skippedVersions;
public ChangelogPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
?? GameChoices.FirstOrDefault();
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && HasChanges && !HasConflict && _state is not null);
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy && _selectedGame is not null);
}
// ------------------------------------------------------------------ Jeu
public IReadOnlyList<Choice> GameChoices { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame)) return;
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les notes modifiées de ce jeu n'ont pas été enregistrées : elles seront perdues.", "Abandonner et changer de jeu"))
{
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
return;
}
_selectedGame = value;
OnPropertyChanged();
Clear(); // rien de l'ancien jeu ne reste affiché (ni enregistrable) si le chargement échoue
_ = LoadAsync();
}
}
// ------------------------------------------------------------------ Versions
public ObservableCollection<VersionEditor> Versions { get; } = new();
public VersionEditor? SelectedVersion
{
get => _selectedVersion;
set
{
// null : la liste de l'écran se vide lors d'un rechargement ; la sélection est rétablie ensuite.
if (value is not null) SetProperty(ref _selectedVersion, value);
}
}
public bool HasVersions => Versions.Count > 0;
public bool ShowNoVersions => _state is not null && Versions.Count == 0;
/// <summary>« Version publiée : 1.1 · 6 versions avec des notes ».</summary>
public string ManifestText => _state is null ? "" :
$"Version publiée : {_state.ManifestVersion} · {Versions.Count} version{(Versions.Count > 1 ? "s" : "")} avec des notes" +
(_skippedVersions > 0 ? $" ({_skippedVersions} non modifiable{(_skippedVersions > 1 ? "s" : "")} en ligne : numéro avec caractères spéciaux ou en double)" : "");
// ------------------------------------------------------------------ État
public bool HasChanges
{
get => _hasChanges;
private set
{
if (SetProperty(ref _hasChanges, value)) SaveCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Le manifest a changé sur le serveur (version publiée, autre administrateur) : il faut recharger.</summary>
public bool HasConflict
{
get => _hasConflict;
private set
{
if (SetProperty(ref _hasConflict, value)) SaveCommand.RaiseCanExecuteChanged();
}
}
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
/// <summary>Faux pendant un chargement ou un enregistrement.</summary>
public bool IsEditable => !IsBusy;
public AsyncRelayCommand SaveCommand { get; }
public AsyncRelayCommand ReloadCommand { get; }
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
SaveCommand.RaiseCanExecuteChanged();
ReloadCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Chargement
public Task LoadAsync() => RunAsync(async () =>
{
if (_selectedGame?.Value is not { } gameId) return;
var state = await Context.Session.GetChangelogAsync(gameId);
Apply(state);
StatusMessage = null;
});
private async Task ReloadAsync()
{
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les notes non enregistrées seront perdues : la page affichera celles du manifest publié.", "Abandonner et recharger"))
return;
await LoadAsync();
}
private void Apply(ChangelogState state)
{
var selected = _selectedVersion?.Version;
_state = state;
HasConflict = false;
Versions.Clear();
// Une version présente deux fois n'est modifiable qu'une fois (le serveur modifie la première).
var editable = state.Entries.Where(e => ChangelogLimits.IsEditableVersion(e.Version))
.DistinctBy(e => e.Version, StringComparer.Ordinal).ToList();
_skippedVersions = state.Entries.Count - editable.Count;
foreach (var entry in editable) Versions.Add(new VersionEditor(entry, Confirm, RefreshChanges));
_selectedVersion = Versions.FirstOrDefault(v => v.Version == selected) ?? Versions.FirstOrDefault();
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
OnPropertyChanged(name);
RefreshChanges();
}
// ------------------------------------------------------------------ Enregistrement
private Task SaveAsync() => RunAsync(async () =>
{
if (_state is null || _selectedGame?.Value is not { } gameId) return;
var dirty = Versions.Where(v => v.IsDirty).ToList();
// Tout est vérifié avant le premier envoi : limites, taille de chaque requête.
foreach (var version in dirty)
{
var entry = version.ToEntry();
var errors = version.LimitErrors; // limites + notes vides
if (errors.Count > 0)
{
SelectedVersion = version;
Context.Dialogs.ShowMessage($"Notes de la version {version.Version} à corriger",
string.Join("\n", errors.Take(12)), ModuleMessageKind.Warning);
return;
}
var size = JsonSerializer.SerializeToUtf8Bytes(new ChangelogUpdateRequest { BaseSha256 = _state.ManifestSha256, Entry = entry }, ApiJson.Options).Length;
if (size > ChangelogLimits.MaxEditRequestBytes)
{
SelectedVersion = version;
Context.Dialogs.ShowMessage("Notes trop longues pour l'édition en ligne",
$"Les notes de la version {version.Version} font {size / 1024} Ko (au plus {ChangelogLimits.MaxEditRequestBytes / 1024} Ko par version). " +
"Raccourcissez-les, ou modifiez notes.txt puis republiez la version.", ModuleMessageKind.Warning);
return;
}
}
var saved = new List<string>();
try
{
foreach (var version in dirty)
{
var state = await Context.Session.SaveChangelogEntryAsync(gameId, version.ToEntry(), _state.ManifestSha256);
_state = state;
if (state.Entries.FirstOrDefault(e => e.Version == version.Version) is { } published) version.Load(published);
saved.Add(version.Version);
}
StatusMessage = $"Notes enregistrées ({string.Join(", ", saved)}). Les launchers les afficheront à leur prochaine recherche de mise à jour.";
Log.Info($"Administration : notes de version modifiées ({gameId} {string.Join(", ", saved)}).");
}
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
{
HasConflict = true;
if (saved.Count > 0) StatusMessage = $"Notes enregistrées : {string.Join(", ", saved)}.";
Context.Dialogs.ShowMessage("Manifest modifié entre-temps",
"Le manifest du jeu a changé sur le serveur (nouvelle version publiée ou autre administrateur). Rechargez la page " +
"pour partir de la version publiée (les modifications non enregistrées seront perdues : notez-les si besoin).",
ModuleMessageKind.Warning);
}
});
// ------------------------------------------------------------------ Outils
private void Clear()
{
_state = null;
HasConflict = false;
StatusMessage = null;
Versions.Clear();
_selectedVersion = null;
_skippedVersions = 0;
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
OnPropertyChanged(name);
RefreshChanges();
SaveCommand.RaiseCanExecuteChanged();
}
private void RefreshChanges() => HasChanges = Versions.Any(v => v.IsDirty);
private bool Confirm(string title, string message) => Context.Dialogs.Confirm(title, message, "Retirer", danger: true);
}
@@ -0,0 +1,25 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Publishing;
/// <summary>
/// Module « Publication » : page du menu d'administration pour mettre en ligne une version produite par
/// le .bat (ManifestBuilder), sans WinSCP. Permission releases.publish (propriétaires, administrateurs).
/// </summary>
public sealed class PublishAdminModule : ILauncherModule
{
public string Id => "publishing";
public string DisplayName => "Publication";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
PublishPanelViewModel.PanelId,
"Publier une version",
"Mettez en ligne la version préparée par le .bat de publication (ManifestBuilder) : seuls les fichiers absents " +
"du serveur sont envoyés, l'envoi reprend après une coupure, et le manifest est publié en dernier.",
"CloudUpload",
AdminPermissions.ReleasesPublish,
Order: 30,
context => new PublishPanelViewModel(context)));
}
@@ -0,0 +1,369 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Publishing;
/// <summary>
/// Page « Publier une version » : choix du manifest.json produit par le .bat (ManifestBuilder), contrôle
/// du dossier, puis envoi au serveur et mise en ligne (remplace l'envoi avec WinSCP).
/// • seuls les fichiers absents du serveur sont envoyés ; l'archive par morceaux ;
/// • « Interrompre » garde ce qui a été envoyé : « Publier » reprend là où l'envoi s'est arrêté ;
/// • le manifest est mis en ligne en dernier : les joueurs ne voient jamais une version incomplète.
/// </summary>
public sealed class PublishPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "releases.publish";
private Choice? _selectedGame;
private LocalRelease? _release;
private string? _manifestPath, _publishedVersion, _statusMessage, _progressText, _speedText;
private string _publishedText = "";
private IReadOnlyList<string> _warnings = Array.Empty<string>();
private bool _isPublishing, _committing;
private double _progressValue;
private PublicationState? _interrupted;
private CancellationTokenSource? _cancellation;
public PublishPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
?? GameChoices.FirstOrDefault();
ChooseCommand = new AsyncRelayCommand(ChooseAsync, () => CanEdit);
RecheckCommand = new AsyncRelayCommand(() => InspectAsync(_manifestPath!), () => CanEdit && _manifestPath is not null);
PublishCommand = new AsyncRelayCommand(PublishAsync, () => CanEdit && _release is not null);
CancelCommand = new RelayCommand(() => _cancellation?.Cancel(), () => _isPublishing && !_committing);
AbandonCommand = new AsyncRelayCommand(AbandonAsync, () => CanEdit && _interrupted is not null);
}
// ------------------------------------------------------------------ Jeu, dossier
public IReadOnlyList<Choice> GameChoices { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame) || _isPublishing) return;
_selectedGame = value;
OnPropertyChanged();
SetRelease(null);
_ = RefreshPublishedAsync();
}
}
public string? ManifestPath
{
get => _manifestPath;
private set
{
if (SetProperty(ref _manifestPath, value)) RecheckCommand.RaiseCanExecuteChanged();
}
}
/// <summary>« Version en ligne : 1.1 » (ou la raison pour laquelle elle est inconnue).</summary>
public string PublishedText
{
get => _publishedText;
private set => SetProperty(ref _publishedText, value);
}
// ------------------------------------------------------------------ Version à publier
public bool HasRelease => _release is not null;
public string VersionText => _release is null ? "" : $"Version {_release.Summary.Version}";
public string? NotesTitle => _release?.Summary.NotesTitle;
public bool HasNotesTitle => !string.IsNullOrWhiteSpace(NotesTitle);
public string FilesText => _release is null ? "" :
$"{_release.Summary.FileCount} fichier(s), {Format.Bytes(_release.Summary.TotalSize)} · exécutable « {_release.Summary.Executable} »";
public string ArchiveText => _release is null ? "" : $"{_release.Summary.ArchiveName} ({Format.Bytes(_release.Summary.ArchiveSize)})";
/// <summary>Points d'attention : republication, retour à une version précédente…</summary>
public IReadOnlyList<string> Warnings
{
get => _warnings;
private set
{
_warnings = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasWarnings));
}
}
public bool HasWarnings => _warnings.Count > 0;
// ------------------------------------------------------------------ Envoi
public bool IsPublishing
{
get => _isPublishing;
private set
{
if (!SetProperty(ref _isPublishing, value)) return;
OnPropertyChanged(nameof(HasInterrupted));
OnPropertyChanged(nameof(CanEdit));
OnPropertyChanged(nameof(ShowBusyBar));
RefreshCommands();
}
}
/// <summary>Faux pendant un contrôle ou un envoi.</summary>
public bool CanEdit => !IsBusy && !_isPublishing;
/// <summary>Barre d'attente du contrôle du dossier (l'envoi a sa propre barre de progression).</summary>
public bool ShowBusyBar => IsBusy && !_isPublishing;
public double ProgressValue
{
get => _progressValue;
private set => SetProperty(ref _progressValue, value);
}
public string? ProgressText
{
get => _progressText;
private set => SetProperty(ref _progressText, value);
}
public string? SpeedText
{
get => _speedText;
private set => SetProperty(ref _speedText, value);
}
/// <summary>
/// Envoi interrompu (annulé, coupure, erreur) : il reprendra à « Publier », ou peut être abandonné (fichiers
/// reçus supprimés). Pas pendant l'envoi lui-même : la publication est alors ouverte, pas interrompue.
/// </summary>
public bool HasInterrupted => _interrupted is not null && !_isPublishing;
public string InterruptedText => _interrupted is null ? "" :
$"L'envoi de la version {_interrupted.Version} a été interrompu. « Publier » le reprend là où il s'est arrêté ; " +
"« Abandonner » supprime ce qui a déjà été reçu par le serveur.";
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand ChooseCommand { get; }
public AsyncRelayCommand RecheckCommand { get; }
public AsyncRelayCommand PublishCommand { get; }
public RelayCommand CancelCommand { get; }
public AsyncRelayCommand AbandonCommand { get; }
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(CanEdit));
OnPropertyChanged(nameof(ShowBusyBar));
RefreshCommands();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? RefreshPublishedAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Actions
private async Task ChooseAsync()
{
var path = Context.Dialogs.PickOpenFile("Manifest de la version à publier (dossier produit par le .bat)",
"Manifest de publication (manifest.json)|manifest.json", _manifestPath is null ? null : Path.GetDirectoryName(_manifestPath));
if (path is not null) await InspectAsync(path);
}
/// <summary>Contrôle du dossier choisi (hors du fil de l'interface : lecture de nombreux fichiers).</summary>
public Task InspectAsync(string manifestPath) => RunAsync(async () =>
{
ManifestPath = manifestPath;
StatusMessage = null;
SetRelease(null);
if (_selectedGame?.Value is not { } gameId) return;
var release = await Task.Run(() => ReleaseUploader.Inspect(manifestPath, gameId));
await RefreshPublishedCoreAsync(gameId);
SetRelease(release);
});
private async Task PublishAsync()
{
if (_release is not { } release || _selectedGame?.Value is not { } gameId) return;
var online = _publishedVersion switch
{
null => "version en ligne inconnue",
"" => "aucune version en ligne",
_ => $"version en ligne : {_publishedVersion}"
};
if (!Context.Dialogs.Confirm($"Publier la version {release.Summary.Version} ?",
$"Les joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
"Les fichiers absents du serveur sont envoyés, puis le manifest est mis en ligne en dernier.", "Publier"))
return;
StatusMessage = null;
_committing = false;
IsPublishing = true;
// Créés ici, sur le fil de l'interface : l'envoi les rappelle sur ce fil (boutons, liaisons).
var progress = new Progress<UploadProgress>(OnProgress);
var started = new Progress<PublicationState>(OnStarted);
_cancellation = new CancellationTokenSource();
try
{
using var _ = Context.BeginLongOperation();
await RunAsync(async () =>
{
try
{
var result = await ReleaseUploader.PublishAsync(Context.Session, gameId, release, progress, started, _cancellation.Token);
SetInterrupted(null);
_publishedVersion = result.Version;
PublishedText = $"Version en ligne : {result.Version}";
StatusMessage = $"Version {result.Version} publiée ({result.ObjectsAdded} fichier(s) envoyé(s)" +
(result.ArchiveAdded ? " et l'archive" : "") +
"). Les launchers la proposeront à leur prochaine recherche de mise à jour.";
UpdateWarnings();
Log.Info($"Administration : version {result.Version} de {gameId} publiée.");
}
catch (OperationCanceledException) when (_cancellation.IsCancellationRequested)
{
StatusMessage = null;
ProgressText = "Envoi interrompu.";
}
});
}
finally
{
_cancellation.Dispose();
_cancellation = null;
_committing = false;
IsPublishing = false;
SpeedText = null;
}
}
private Task AbandonAsync() => RunAsync(async () =>
{
if (_interrupted is not { } publication) return;
if (!Context.Dialogs.Confirm("Abandonner la publication ?",
$"Les fichiers de la version {publication.Version} déjà reçus par le serveur seront supprimés. Rien n'a été mis en ligne.",
"Abandonner", danger: true))
return;
try
{
await Context.Session.CancelPublicationAsync(publication.PublicationId);
}
catch (ApiException ex) when (ex.Status == 404)
{
// déjà terminée ou expirée
}
SetInterrupted(null);
ProgressText = null;
ProgressValue = 0;
});
// ------------------------------------------------------------------ Outils
private Task RefreshPublishedAsync() => _selectedGame?.Value is { } gameId ? RefreshPublishedCoreAsync(gameId) : Task.CompletedTask;
/// <summary>Version en ligne (manifest du serveur) ; une erreur ici n'empêche pas de continuer.</summary>
private async Task RefreshPublishedCoreAsync(string gameId)
{
try
{
var state = await Context.Session.GetChangelogAsync(gameId);
_publishedVersion = state.ManifestVersion;
PublishedText = $"Version en ligne : {Display(state.ManifestVersion)}";
}
catch (ApiException ex) when (ex.Status == 404)
{
_publishedVersion = "";
PublishedText = "Aucune version en ligne pour l'instant.";
}
catch (ApiException ex) when (ex.Code == ApiErrorCodes.NotConfigured)
{
_publishedVersion = null;
PublishedText = "Publication non configurée sur le serveur pour ce jeu (LauncherApi:Games, voir server/deploy/LISEZMOI.md).";
}
catch (Exception ex) when (!ApiErrorText.IsSessionLost(ex))
{
_publishedVersion = null;
PublishedText = $"Version en ligne inconnue : {ApiErrorText.Describe(ex)}";
}
UpdateWarnings();
}
private void SetRelease(LocalRelease? release)
{
_release = release;
foreach (var name in new[] { nameof(HasRelease), nameof(VersionText), nameof(NotesTitle), nameof(HasNotesTitle), nameof(FilesText), nameof(ArchiveText) })
OnPropertyChanged(name);
UpdateWarnings();
RefreshCommands();
}
private void UpdateWarnings()
{
var warnings = new List<string>();
if (_release is { } release && !string.IsNullOrEmpty(_publishedVersion))
{
if (release.Summary.Version == _publishedVersion)
warnings.Add("Cette version est déjà en ligne : elle sera republiée (fichiers, archive et notes de cette version remplacés).");
else if (VersionUtil.TryParse(release.Summary.Version, out var local) && VersionUtil.TryParse(_publishedVersion, out var online) &&
local < online)
warnings.Add($"Version inférieure à celle en ligne ({_publishedVersion}) : les joueurs reviendront à la version {release.Summary.Version}.");
}
if (_release is { Summary.NotesTitle: null })
warnings.Add("Aucune note pour cette version dans le manifest (celles déjà en ligne pour ce numéro sont conservées).");
Warnings = warnings;
}
private void OnStarted(PublicationState state)
{
SetInterrupted(state);
if (state.Resumed) ProgressText = "Reprise de l'envoi interrompu…";
}
private void SetInterrupted(PublicationState? state)
{
_interrupted = state;
OnPropertyChanged(nameof(HasInterrupted));
OnPropertyChanged(nameof(InterruptedText));
AbandonCommand.RaiseCanExecuteChanged();
}
private void OnProgress(UploadProgress progress)
{
if (progress.Step == ReleaseUploader.CommitStep && !_committing)
{
_committing = true; // la mise en ligne ne s'interrompt pas
CancelCommand.RaiseCanExecuteChanged();
}
ProgressValue = progress.Total > 0 ? Math.Clamp((double)progress.Done / progress.Total, 0, 1) : 0;
ProgressText = progress.Total > 0
? $"{progress.Step} — {Format.Bytes(progress.Done)} / {Format.Bytes(progress.Total)}"
: progress.Step;
SpeedText = progress.BytesPerSecond > 0 ? Format.Speed(progress.BytesPerSecond) : null;
}
private void RefreshCommands()
{
ChooseCommand.RaiseCanExecuteChanged();
RecheckCommand.RaiseCanExecuteChanged();
PublishCommand.RaiseCanExecuteChanged();
CancelCommand.RaiseCanExecuteChanged();
AbandonCommand.RaiseCanExecuteChanged();
}
private static string Display(string version) => string.IsNullOrEmpty(version) ? "aucune" : version;
}
@@ -0,0 +1,330 @@
using System.Diagnostics;
using System.Security.Cryptography;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
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)
{
public string ArchivePath => Path.Combine(Folder, Summary.ArchiveName);
public string ObjectFile(string sha256) =>
Path.Combine(Folder, ManifestCheck.ObjectPath(sha256).Replace('/', Path.DirectorySeparatorChar));
}
/// <summary>Avancement de l'envoi.</summary>
public sealed record UploadProgress(string Step, long Done, long Total, double BytesPerSecond);
/// <summary>Contenu du dossier de publication incorrect (message affiché tel quel).</summary>
public sealed class ReleaseCheckException : InvalidOperationException
{
public ReleaseCheckException(string message) : base(message) { }
}
/// <summary>
/// Publication d'une version : contrôle du dossier local, puis envoi au serveur (manifest, objets absents
/// du serveur, archive) et mise en ligne. Reprise automatique : après une coupure (réseau, serveur
/// occupé, trop de requêtes), l'état est relu sur le serveur et l'envoi continue là où il s'est arrêté ;
/// relancé plus tard avec le même manifest, l'envoi reprend aussi.
/// </summary>
public static class ReleaseUploader
{
private const int MaxAttempts = 8;
/// <summary>Dernière étape (mise en ligne sur le serveur) : elle ne s'interrompt pas.</summary>
public const string CommitStep = "Mise en ligne";
/// <summary>
/// Contrôle du dossier : manifest valide (mêmes règles que le serveur), du bon jeu, archive et fichiers
/// présents avec la bonne taille (empreintes vérifiées pendant l'envoi).
/// </summary>
public static LocalRelease Inspect(string manifestPath, string gameId)
{
var info = new FileInfo(manifestPath);
if (!info.Exists) throw new ReleaseCheckException($"Manifest introuvable : {manifestPath}");
if (info.Length > PublishingLimits.MaxManifestBytes) throw new ReleaseCheckException("Manifest trop volumineux.");
var bytes = File.ReadAllBytes(info.FullName);
var errors = new List<string>();
var summary = ManifestCheck.Check(bytes, gameId, expectedVersion: null, errors)
?? throw new ReleaseCheckException("Ce manifest ne peut pas être publié :\n" + string.Join("\n", errors.Take(8)));
var release = new LocalRelease(info.DirectoryName!, info.FullName, bytes, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), summary);
var archive = new FileInfo(release.ArchivePath);
if (!archive.Exists) errors.Add($"Archive absente : {summary.ArchiveName}");
else if (archive.Length != summary.ArchiveSize) errors.Add($"Archive {summary.ArchiveName} : taille différente de celle du manifest.");
var missing = 0;
foreach (var (sha, size) in summary.Objects)
{
var file = new FileInfo(release.ObjectFile(sha));
if (file.Exists && file.Length == size) continue;
if (++missing <= 5) errors.Add($"{ManifestCheck.ObjectPath(sha)} : {(file.Exists ? "taille différente" : "absent")}.");
}
if (missing > 5) errors.Add($"… et {missing - 5} autre(s) fichier(s) de objects/.");
if (errors.Count > 0)
throw new ReleaseCheckException("Dossier de publication incomplet (relancez le .bat de publication) :\n" + string.Join("\n", errors));
return release;
}
/// <summary>Envoie ce qui manque puis met la version en ligne.</summary>
/// <param name="progress">Avancement ; un <see cref="Progress{T}"/> créé sur le fil de l'interface y est rappelé.</param>
/// <param name="started">Publication ouverte sur le serveur (pour l'abandonner si besoin). Comme <paramref name="progress"/>,
/// passer un <see cref="Progress{T}"/> : l'envoi se poursuit hors du fil de l'interface (ConfigureAwait(false)).</param>
public static async Task<PublicationResult> PublishAsync(AdminSession session, string gameId, LocalRelease release,
IProgress<UploadProgress>? progress, IProgress<PublicationState>? started, CancellationToken ct)
{
var run = new Run(session, release, progress);
var state = await Retry(() => session.StartPublicationAsync(gameId, new PublicationStartRequest
{
Version = release.Summary.Version,
ManifestSha256 = release.ManifestSha256,
ManifestSize = release.ManifestBytes.LongLength
}, ct), ct).ConfigureAwait(false);
started?.Report(state);
var committing = false;
for (var attempt = 1; ; attempt++)
{
var before = run.Done;
try
{
if (!committing)
{
if (attempt > 1) state = await session.GetPublicationAsync(state.PublicationId, ct).ConfigureAwait(false);
await run.ManifestAsync(state, ct).ConfigureAwait(false);
if (!state.ManifestAccepted) state = await session.GetPublicationAsync(state.PublicationId, ct).ConfigureAwait(false);
run.Plan(state);
await run.ObjectsAsync(state, ct).ConfigureAwait(false);
await run.ArchiveAsync(state, ct).ConfigureAwait(false);
run.Report(CommitStep);
committing = true;
}
// 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);
}
catch (ApiException ex) when (IsTransient(ex) && attempt < MaxAttempts)
{
if (run.Done > before) attempt = 1; // l'envoi avance : les essais repartent de zéro
var delay = Backoff(attempt, ex);
if (delay > TimeSpan.Zero) run.Waiting(PauseReason(ex), delay);
await Task.Delay(delay, committing ? CancellationToken.None : ct).ConfigureAwait(false);
}
}
}
// ------------------------------------------------------------------ Envoi
private sealed class Run
{
private readonly AdminSession _session;
private readonly LocalRelease _release;
private readonly IProgress<UploadProgress>? _progress;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private long _total, _done, _sentThisRun;
public long Done => _done;
private string _step = "Préparation";
public Run(AdminSession session, LocalRelease release, IProgress<UploadProgress>? progress)
{
_session = session;
_release = release;
_progress = progress;
}
public async Task ManifestAsync(PublicationState state, CancellationToken ct)
{
if (state.ManifestReceived >= state.ManifestSize) return;
if (_total == 0) _total = state.ManifestSize - state.ManifestReceived;
_step = "Envoi du manifest";
var bytes = _release.ManifestBytes;
for (var offset = state.ManifestReceived; offset < bytes.LongLength;)
{
var length = (int)Math.Min(PublishingLimits.ChunkBytes, bytes.LongLength - offset);
await _session.UploadManifestAsync(state.PublicationId, offset, bytes.AsSpan((int)offset, length).ToArray(), ct).ConfigureAwait(false);
offset += length;
Sent(length);
}
}
/// <summary>Total à envoyer, connu une fois le manifest accepté (calculé une seule fois).</summary>
public void Plan(PublicationState state)
{
if (_total > state.ManifestSize || !state.ManifestAccepted) return;
var objects = state.PendingObjects.Sum(sha => Size(sha) - state.PartialObjects.GetValueOrDefault(sha));
var archive = state.ArchiveComplete ? 0 : state.ArchiveSize - state.ArchiveReceived;
_total = _done + objects + archive;
Report(_step);
}
public async Task ObjectsAsync(PublicationState state, CancellationToken ct)
{
var pending = state.PendingObjects.Select(sha => sha.ToLowerInvariant()).ToList();
if (pending.Count == 0) return;
var count = 0;
void Step() => _step = $"Envoi des fichiers du jeu ({count}/{pending.Count})";
// Petits fichiers : par lots (une requête pour des centaines de fichiers).
var batch = new List<(string Sha, byte[] Data)>();
long batchBytes = 0;
async Task FlushAsync()
{
if (batch.Count == 0) return;
await _session.UploadObjectBatchAsync(state.PublicationId, ObjectBatch.Write(batch), ct).ConfigureAwait(false);
count += batch.Count;
Step();
Sent(batchBytes);
batch.Clear();
batchBytes = 0;
}
foreach (var sha in pending.Where(s => Size(s) <= PublishingLimits.BatchObjectMaxBytes))
{
ct.ThrowIfCancellationRequested();
var data = await File.ReadAllBytesAsync(_release.ObjectFile(sha), ct).ConfigureAwait(false);
if (Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant() != sha) throw Changed(ManifestCheck.ObjectPath(sha));
batch.Add((sha, data));
batchBytes += data.Length;
if (batchBytes >= PublishingLimits.ChunkBytes || batch.Count >= PublishingLimits.MaxObjectsPerBatch) await FlushAsync().ConfigureAwait(false);
}
await FlushAsync().ConfigureAwait(false);
// Gros fichiers : par morceaux, reprise à la position déjà reçue.
foreach (var sha in pending.Where(s => Size(s) > PublishingLimits.BatchObjectMaxBytes))
{
Step();
await FileAsync(_release.ObjectFile(sha), sha, state.PartialObjects.GetValueOrDefault(sha), ManifestCheck.ObjectPath(sha),
(offset, data) => _session.UploadObjectAsync(state.PublicationId, sha, offset, data, ct), ct).ConfigureAwait(false);
count++;
}
}
public async Task ArchiveAsync(PublicationState state, CancellationToken ct)
{
if (state.ArchiveComplete) return;
_step = $"Envoi de l'archive {_release.Summary.ArchiveName}";
await FileAsync(_release.ArchivePath, _release.Summary.ArchiveSha256, state.ArchiveReceived, _release.Summary.ArchiveName,
(offset, data) => _session.UploadArchiveAsync(state.PublicationId, offset, data, ct), ct).ConfigureAwait(false);
}
/// <summary>
/// Envoi d'un fichier à partir de <paramref name="from"/> ; le fichier est relu en entier pour vérifier
/// son empreinte (un fichier modifié depuis la génération est refusé avant la mise en ligne).
/// </summary>
private async Task FileAsync(string path, string sha256, long from, string name, Func<long, byte[], Task<ChunkResult>> upload,
CancellationToken ct)
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 16, useAsync: true);
var buffer = new byte[PublishingLimits.ChunkBytes];
long offset = 0;
while (true)
{
var read = await ReadFullAsync(stream, buffer, ct).ConfigureAwait(false);
if (read == 0) break;
hash.AppendData(buffer, 0, read);
if (offset + read > from)
{
var skip = (int)Math.Max(0, from - offset);
var chunk = buffer.AsSpan(skip, read - skip).ToArray();
// Dernier morceau : l'empreinte locale est vérifiée AVANT de l'envoyer.
if (offset + read == stream.Length &&
Convert.ToHexString(hash.GetCurrentHash()).ToLowerInvariant() != sha256.ToLowerInvariant())
throw Changed(name);
await upload(offset + skip, chunk).ConfigureAwait(false);
Sent(chunk.Length);
}
offset += read;
}
if (Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant() != sha256.ToLowerInvariant()) throw Changed(name);
}
/// <summary>Pause avant un nouvel essai : affichée (sinon l'envoi semble figé), sans changer d'étape.</summary>
public void Waiting(string reason, TimeSpan delay) =>
_progress?.Report(new UploadProgress($"{_step} — {reason}, reprise dans {Math.Max(1, (int)Math.Ceiling(delay.TotalSeconds))} s…",
Math.Min(_done, Math.Max(_total, _done)), Math.Max(_total, _done), 0));
public void Report(string step)
{
_step = step;
var seconds = _clock.Elapsed.TotalSeconds;
_progress?.Report(new UploadProgress(step, Math.Min(_done, Math.Max(_total, _done)), Math.Max(_total, _done),
seconds > 0.5 ? _sentThisRun / seconds : 0));
}
private void Sent(long bytes)
{
_done += bytes;
_sentThisRun += bytes;
Report(_step);
}
private long Size(string sha) => _release.Summary.Objects.TryGetValue(sha.ToLowerInvariant(), out var size)
? size
: throw new ReleaseCheckException("Le serveur attend un fichier absent de ce manifest : relancez la publication.");
private static ReleaseCheckException Changed(string name) => new(
$"« {name} » a changé depuis la génération de la version (son empreinte ne correspond plus au manifest) : relancez le .bat de publication.");
private static async Task<int> ReadFullAsync(Stream stream, byte[] buffer, CancellationToken ct)
{
var total = 0;
while (total < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(total), ct).ConfigureAwait(false);
if (read == 0) break;
total += read;
}
return total;
}
}
// ------------------------------------------------------------------ Coupures
/// <summary>Coupure réseau, délai dépassé, serveur occupé, trop de requêtes, position décalée : on reprend.</summary>
private static bool IsTransient(ApiException ex) =>
ex.Status is 0 or 429 or 502 or 504 ||
(ex.Status == 503 && ex.Code == ApiErrorCodes.Busy) ||
(ex.Status == 409 && ex.Code == ApiErrorCodes.OffsetMismatch);
/// <summary>
/// Attente avant un nouvel essai. Trop de requêtes : le délai annoncé par le serveur (Retry-After),
/// sinon (limite de nginx, qui se libère en une fraction de seconde) une pause courte qui double à
/// chaque refus consécutif.
/// </summary>
internal static TimeSpan Backoff(int attempt, ApiException ex)
{
if (ex.Code == ApiErrorCodes.OffsetMismatch) return TimeSpan.Zero;
if (ex.Status == 429)
return ex.RetryAfter is { } announced
? TimeSpan.FromSeconds(Math.Clamp(Math.Ceiling(announced.TotalSeconds), 1, 60))
: TimeSpan.FromSeconds(Math.Min(30, 1 << Math.Min(attempt - 1, 5)));
return TimeSpan.FromSeconds(Math.Min(30, 2 * attempt * attempt));
}
private static string PauseReason(ApiException ex) => ex.Status switch
{
429 => "limite de requêtes du serveur atteinte",
0 => "connexion interrompue",
503 => "serveur occupé",
_ => "serveur indisponible"
};
private static async Task<T> Retry<T>(Func<Task<T>> action, CancellationToken ct)
{
for (var attempt = 1; ; attempt++)
{
try
{
return await action().ConfigureAwait(false);
}
catch (ApiException ex) when (IsTransient(ex) && attempt < MaxAttempts)
{
await Task.Delay(Backoff(attempt, ex), ct).ConfigureAwait(false);
}
}
}
}
@@ -63,6 +63,29 @@ public sealed class AdminSessionGuard : IDisposable
}
}
private int _longOperations;
/// <summary>Opération longue (envoi d'une version) : compte comme une activité jusqu'à sa fin.</summary>
public IDisposable BeginLongOperation()
{
Interlocked.Increment(ref _longOperations);
return new LongOperation(this);
}
private sealed class LongOperation : IDisposable
{
private AdminSessionGuard? _guard;
public LongOperation(AdminSessionGuard guard) => _guard = guard;
public void Dispose()
{
if (Interlocked.Exchange(ref _guard, null) is not { } guard) return;
Interlocked.Decrement(ref guard._longOperations);
guard._lastActivity = guard._time.GetUtcNow(); // 10 minutes à compter de la fin de l'opération
}
}
/// <summary>Activité de l'administrateur (souris, clavier) dans la fenêtre d'administration.</summary>
public void NotifyActivity()
{
@@ -76,6 +99,7 @@ public sealed class AdminSessionGuard : IDisposable
{
if (_locked || _disposed) return;
var now = _time.GetUtcNow();
if (Volatile.Read(ref _longOperations) > 0) _lastActivity = now;
if (now >= _connection.LocalExpiresAt)
{
await LockAsync("La session a atteint sa durée maximale : reconnectez-vous.");
@@ -127,6 +127,8 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
public string LauncherVersion => _shell.Environment.LauncherVersion;
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
public void Navigate(string panelId)
{
if (PanelItems.FirstOrDefault(p => string.Equals(p.Descriptor.Id, panelId, StringComparison.OrdinalIgnoreCase)) is { } item)
@@ -34,4 +34,11 @@ public interface IAdminContext
/// <summary>La session n'est plus valable : retour à l'écran de connexion avec ce message.</summary>
void ReportSessionLost(string reason);
/// <summary>
/// Opération longue en cours (envoi d'une version…) : tant que l'objet renvoyé n'est pas libéré,
/// elle compte comme une activité (pas de verrouillage pour inactivité, session prolongée). Le
/// verrouillage manuel, celui de Windows et la durée maximale de la session restent appliqués.
/// </summary>
IDisposable BeginLongOperation();
}
+30 -2
View File
@@ -7,6 +7,7 @@ public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
private readonly OwnerThread _owner = OwnerThread.Current();
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
@@ -23,7 +24,7 @@ public sealed class RelayCommand : ICommand
if (CanExecute(parameter)) _execute();
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
public void RaiseCanExecuteChanged() => _owner.Run(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty));
}
/// <summary>Commande asynchrone : désactivée pendant son exécution (pas de double clic).</summary>
@@ -31,6 +32,7 @@ public sealed class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private readonly OwnerThread _owner = OwnerThread.Current();
private bool _isRunning;
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
@@ -67,5 +69,31 @@ public sealed class AsyncRelayCommand : ICommand
}
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
public void RaiseCanExecuteChanged() => _owner.Run(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty));
}
/// <summary>
/// Fil qui a créé la commande (celui de l'interface sous WPF). Un bouton n'accepte d'être notifié que
/// depuis ce fil : appelée d'ailleurs (suite d'un ConfigureAwait(false)…), la notification y est renvoyée
/// au lieu de lever « un autre thread en est propriétaire ».
/// </summary>
internal readonly struct OwnerThread
{
private readonly int _threadId;
private readonly SynchronizationContext? _context;
private OwnerThread(int threadId, SynchronizationContext? context)
{
_threadId = threadId;
_context = context;
}
public static OwnerThread Current() => new(Environment.CurrentManagedThreadId, SynchronizationContext.Current);
public void Run(Action action)
{
// Même fil, ou aucun fil d'interface connu (tests, services) : exécution directe.
if (_context is null || Environment.CurrentManagedThreadId == _threadId) action();
else _context.Post(static state => ((Action)state!)(), action);
}
}
+5 -1
View File
@@ -14,6 +14,8 @@ using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Platform;
using GameLauncher.Services;
using GameLauncher.ViewModels;
@@ -504,7 +506,9 @@ public partial class App : Application, ILauncherRestartHost
private static ModuleCatalog CreateModules() => new(new ILauncherModule[]
{
new AdminModule(),
new CatalogAdminModule()
new CatalogAdminModule(),
new ChangelogAdminModule(),
new PublishAdminModule()
});
/// <summary>
+465 -1
View File
@@ -2,7 +2,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:GameLauncher.Controls"
xmlns:avm="clr-namespace:GameLauncher.Modules.Admin.ViewModels;assembly=GameLauncher.Modules.Admin"
xmlns:acat="clr-namespace:GameLauncher.Modules.Admin.Catalog;assembly=GameLauncher.Modules.Admin">
xmlns:acat="clr-namespace:GameLauncher.Modules.Admin.Catalog;assembly=GameLauncher.Modules.Admin"
xmlns:achl="clr-namespace:GameLauncher.Modules.Admin.Changelog;assembly=GameLauncher.Modules.Admin"
xmlns:apub="clr-namespace:GameLauncher.Modules.Admin.Publishing;assembly=GameLauncher.Modules.Admin">
<!--
Menu d'administration (Views/Admin) : styles communs et modèles des pages des modules.
@@ -694,4 +696,466 @@
</StackPanel>
</DataTemplate>
<!-- ============================================================ Page « Notes de version » -->
<!-- Version de la liste : numéro, date, pastille « modifié ». -->
<Style x:Key="AdminVersionItemStyle" TargetType="ListBoxItem">
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" CornerRadius="12" Padding="12,8" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock x:Name="Number" Text="{Binding Version}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding DateText}" FontSize="11.5" Margin="0,1,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
<Ellipse x:Name="Dirty" Grid.Column="1" Width="8" Height="8" Margin="8,0,0,0" VerticalAlignment="Center"
Fill="{DynamicResource WarningBrush}" Visibility="Collapsed" ToolTip="Modifié, non enregistré" />
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
<Setter TargetName="Number" Property="Foreground" Value="{DynamicResource Accent2Brush}" />
</Trigger>
<DataTrigger Binding="{Binding IsDirty}" Value="True">
<Setter TargetName="Dirty" Property="Visibility" Value="Visible" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Bouton d'un sélecteur segmenté (« Modifier » / « Aperçu ») : RadioButton, ui:IconAssist.Icon = icône. -->
<Style x:Key="AdminSegmentStyle" TargetType="RadioButton">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Border x:Name="Bd" CornerRadius="9" Padding="12,6" Background="Transparent">
<StackPanel Orientation="Horizontal">
<Path x:Name="Icon" Width="13" Height="13" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,7,0"
Fill="{TemplateBinding Foreground}"
Data="{Binding Path=(ui:IconAssist.Icon), RelativeSource={RelativeSource TemplatedParent}}" />
<ContentPresenter VerticalAlignment="Center" TextElement.FontWeight="SemiBold" />
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentGradientBrush}" />
<Setter Property="Foreground" Value="{DynamicResource OnAccentBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<DataTemplate DataType="{x:Type achl:ChangelogPanelViewModel}">
<StackPanel>
<!-- Erreur (chargement, jeu non configuré sur le serveur, refus…) -->
<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}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<!-- Manifest modifié entre-temps -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasConflict, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Recharger" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}" />
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.History}"
VerticalAlignment="Center" />
<TextBlock Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center"
Text="Le manifest du jeu a changé sur le serveur pendant vos modifications : rechargez avant d'enregistrer." />
</DockPanel>
</Border>
<!-- Résultat du dernier enregistrement -->
<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>
<!-- Jeu (plusieurs jeux déclarés dans launcher.json) -->
<DockPanel Margin="0,0,0,14" Visibility="{Binding HasSeveralGames, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Jeu" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,12,0" VerticalAlignment="Center" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="340" HorizontalAlignment="Left"
ItemsSource="{Binding GameChoices}" SelectedItem="{Binding SelectedGame, Mode=TwoWay}" DisplayMemberPath="Label"
IsEnabled="{Binding IsEditable}" />
</DockPanel>
<!-- Aucune note dans le manifest publié -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding ShowNoVersions, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<TextBlock Text="Aucune note de version" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
Text="Le manifest publié ne contient aucune note. Les notes d'une version se publient avec ManifestBuilder (notes.txt), puis se corrigent ici." />
</StackPanel>
</Border>
<!-- Saisie suspendue pendant un chargement ou un enregistrement -->
<Grid IsEnabled="{Binding IsEditable}" Visibility="{Binding HasVersions, Converter={StaticResource BoolToVisibility}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<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" />
</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>
<!-- Notes d'une version : modification ou aperçu -->
<DataTemplate DataType="{x:Type achl:VersionEditor}">
<StackPanel>
<DockPanel Margin="0,0,0,14">
<Border DockPanel.Dock="Right" CornerRadius="11" Padding="3" VerticalAlignment="Center"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
<StackPanel Orientation="Horizontal">
<RadioButton Style="{StaticResource AdminSegmentStyle}" Content="Modifier" IsChecked="{Binding IsEditMode, Mode=TwoWay}"
ui:IconAssist.Icon="{StaticResource Icon.Wrench}" />
<RadioButton Style="{StaticResource AdminSegmentStyle}" Content="Aperçu" IsChecked="{Binding IsPreviewMode, Mode=TwoWay}"
ui:IconAssist.Icon="{StaticResource Icon.Play}" Margin="2,0,0,0" />
</StackPanel>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock FontSize="16" FontWeight="SemiBold">
<Run Text="Version" />
<Run Text="{Binding Version, Mode=OneWay}" />
</TextBlock>
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0">
<Run Text="{Binding DateText, Mode=OneWay}" />
<Run Text="·" />
<Run Text="{Binding CountText, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</DockPanel>
<!-- Limites dépassées : à corriger avant d'enregistrer -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasLimitErrors, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<TextBlock Text="À corriger avant d'enregistrer" FontSize="12.5" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding LimitErrors}" Margin="0,4,0,0" Focusable="False">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="12" TextWrapping="Wrap" Margin="0,1,0,0" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Modification -->
<StackPanel Visibility="{Binding IsEditMode, Converter={StaticResource BoolToVisibility}}">
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<TextBlock Text="En-tête" Style="{StaticResource AdminCardTitleStyle}" Margin="0,0,0,12" />
<TextBlock Text="Titre" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding Title, UpdateSourceTrigger=PropertyChanged}" MaxLength="200" />
<TextBlock Text="Sous-titre (une ou plusieurs lignes)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,12,0,6" />
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="58"
Text="{Binding Subtitle, UpdateSourceTrigger=PropertyChanged}" MaxLength="400" />
<TextBlock Text="Introduction" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,12,0,6" />
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" Text="{Binding Introduction, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,4,0,0"
Text="Paragraphes séparés par une ligne vide (20 au plus), affichés avant les sections." />
</StackPanel>
</Border>
<ItemsControl ItemsSource="{Binding Sections}" Focusable="False" />
<Button Style="{StaticResource SmallButtonStyle}" HorizontalAlignment="Left" Content="Ajouter une section"
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding AddSectionCommand}" />
</StackPanel>
<!-- Aperçu : modèles d'affichage du launcher (Themes/Changelog.xaml), mêmes bornes -->
<StackPanel Visibility="{Binding IsPreviewMode, Converter={StaticResource BoolToVisibility}}">
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,0,0,10"
Text="Tel que les joueurs le verront dans le launcher (textes nettoyés et bornés de la même façon)." />
<ContentControl Content="{Binding Preview}" ContentTemplate="{StaticResource ChangelogEntryTemplate}" Focusable="False" />
</StackPanel>
</StackPanel>
</DataTemplate>
<!-- Une section : titre, éléments -->
<DataTemplate DataType="{x:Type achl:SectionEditor}">
<Border Style="{StaticResource AdminCardStyle}" Padding="16,14">
<StackPanel>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Style="{StaticResource AdminFieldLabelStyle}">
<Run Text="Section" />
<Run Text="·" Foreground="{DynamicResource TextMutedBrush}" />
<Run Text="{Binding CountText, Mode=OneWay}" Foreground="{DynamicResource TextMutedBrush}" FontWeight="Normal" />
</TextBlock>
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding Title, UpdateSourceTrigger=PropertyChanged}"
MaxLength="200" ToolTip="Titre de la section (facultatif), ex. « PROGRESSION »" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Bottom" Margin="8,0,0,5">
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" ToolTip="Monter la section" Command="{Binding MoveUpCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.ArrowDown}" ToolTip="Descendre la section" Command="{Binding MoveDownCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" ToolTip="Retirer la section" Command="{Binding RemoveCommand}" />
</StackPanel>
</Grid>
<ItemsControl ItemsSource="{Binding Items}" Focusable="False" />
<StackPanel Orientation="Horizontal" Margin="0,2,0,0">
<Button Style="{StaticResource SmallButtonStyle}" Content="Modification" Margin="0,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding AddChangeCommand}" ToolTip="Ajouter une modification" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Commentaire"
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding AddCommentCommand}" ToolTip="Ajouter un commentaire (texte en italique)" />
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
<!-- Un élément : modification (étiquette, sous-points, note) ou commentaire -->
<DataTemplate DataType="{x:Type achl:ItemEditor}">
<Border CornerRadius="12" Padding="12,10" Margin="0,0,0,8" BorderThickness="1"
Background="{DynamicResource CardBrush}" BorderBrush="{DynamicResource BorderBrush}">
<StackPanel>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Height="34" ItemsSource="{Binding KindChoices}"
SelectedItem="{Binding Kind, Mode=TwoWay}" DisplayMemberPath="Label" ToolTip="Type d'élément" />
<TextBox Grid.Column="1" Style="{StaticResource ModernTextBoxStyle}" Height="34" Width="130" Margin="8,0,0,0" MaxLength="24"
Text="{Binding Tag, UpdateSourceTrigger=PropertyChanged}" ToolTip="Étiquette en pastille (facultatif), ex. « NOUVEAU »"
Visibility="{Binding IsChange, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Grid.Column="3" Orientation="Horizontal">
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" ToolTip="Monter" Command="{Binding MoveUpCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.ArrowDown}" ToolTip="Descendre" Command="{Binding MoveDownCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" ToolTip="Retirer" Command="{Binding RemoveCommand}" />
</StackPanel>
</Grid>
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="40" AcceptsReturn="False" MaxLength="2000"
Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" ToolTip="Texte" />
<StackPanel Visibility="{Binding IsChange, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Sous-points (un par ligne)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,10,0,6" />
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="40"
Text="{Binding Details, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="Note de fin (facultatif)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,10,0,6" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Height="34" MaxLength="2000"
Text="{Binding Note, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
<!-- ============================================================ Page « Publier une version » -->
<DataTemplate DataType="{x:Type apub:PublishPanelViewModel}">
<StackPanel>
<!-- Erreur (contrôle du dossier, refus du serveur…) -->
<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>
<!-- Version publiée -->
<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>
<!-- Envoi interrompu : reprise ou abandon -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasInterrupted, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Abandonner" Margin="12,0,0,0"
VerticalAlignment="Center" Foreground="{DynamicResource DangerBrush}"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding AbandonCommand}" />
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Pause}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding InterruptedText}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center" />
</DockPanel>
</Border>
<!-- Jeu (plusieurs jeux déclarés dans launcher.json) -->
<DockPanel Margin="0,0,0,14" Visibility="{Binding HasSeveralGames, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Jeu" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,12,0" VerticalAlignment="Center" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="340" HorizontalAlignment="Left"
ItemsSource="{Binding GameChoices}" SelectedItem="{Binding SelectedGame, Mode=TwoWay}" DisplayMemberPath="Label"
IsEnabled="{Binding CanEdit}" />
</DockPanel>
<!-- Dossier de publication -->
<Border Style="{StaticResource AdminCardStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminIconBadgeStyle}" VerticalAlignment="Top">
<Path Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.PackageVariant}" />
</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>
<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"
Command="{Binding RecheckCommand}" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Choisir le manifest.json…"
ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}" Command="{Binding ChooseCommand}" />
</StackPanel>
<TextBlock VerticalAlignment="Center" FontSize="12.5" TextTrimming="CharacterEllipsis" ToolTip="{Binding ManifestPath}"
Text="{Binding ManifestPath, TargetNullValue='Choisissez le manifest.json produit par le .bat de publication (dossier --output).'}"
Foreground="{DynamicResource TextSecondaryBrush}" />
</DockPanel>
</StackPanel>
</Grid>
</Border>
<!-- Version à publier (dossier contrôlé) -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding HasRelease, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<DockPanel>
<Border DockPanel.Dock="Right" Style="{StaticResource AdminChipStyle}" Margin="12,0,0,0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<Path Width="11" Height="11" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.Check}"
VerticalAlignment="Center" />
<TextBlock Text="Dossier vérifié" FontSize="11.5" Margin="6,0,0,0" />
</StackPanel>
</Border>
<TextBlock Text="{Binding VersionText}" FontSize="18" FontWeight="SemiBold" VerticalAlignment="Center" />
</DockPanel>
<TextBlock Text="{Binding NotesTitle}" FontSize="13" Margin="0,4,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding HasNotesTitle, Converter={StaticResource BoolToVisibility}}" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Fichiers" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Column="1" Text="{Binding FilesText}" Style="{StaticResource AdminValueStyle}" />
<TextBlock Grid.Row="1" Text="Archive" Style="{StaticResource AdminLabelStyle}" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding ArchiveText}" Style="{StaticResource AdminValueStyle}" />
</Grid>
<ItemsControl ItemsSource="{Binding Warnings}" Focusable="False" Margin="0,4,0,0"
Visibility="{Binding HasWarnings, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
VerticalAlignment="Top" Margin="0,2,0,0" />
<TextBlock Text="{Binding}" Margin="8,0,0,0" FontSize="12.5" TextWrapping="Wrap" MaxWidth="600" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</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>
</ResourceDictionary>
+5
View File
@@ -86,4 +86,9 @@
<Geometry x:Key="Icon.Restore">M13,3A9,9 0 0,0 4,12H1L4.89,15.89L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3Z</Geometry>
<Geometry x:Key="Icon.ContentSaveOutline">M17 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.1 21 21 20.1 21 19V7L17 3M19 19H5V5H16.17L19 7.83V19M12 12C10.34 12 9 13.34 9 15S10.34 18 12 18 15 16.66 15 15 13.66 12 12 12M6 6H15V10H6V6Z</Geometry>
<!-- Publication d'une version (phase 5) -->
<Geometry x:Key="Icon.CloudUpload">M14,13V17H10V13H7L12,8L17,13M19.35,10.03C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.03C2.34,8.36 0,10.9 0,14A6,6 0 0,0 6,20H19A5,5 0 0,0 24,15C24,12.36 21.95,10.22 19.35,10.03Z</Geometry>
<Geometry x:Key="Icon.Pause">M14,19H18V5H14M6,19H10V5H6V19Z</Geometry>
<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>
</ResourceDictionary>
@@ -3,6 +3,7 @@ using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Services;
using GameLauncher.Core.Utils;
@@ -1,3 +1,4 @@
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Mvvm;
@@ -1,5 +1,6 @@
using System.Windows;
using System.Windows.Input;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
namespace GameLauncher.Views;
@@ -0,0 +1,172 @@
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Server;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Notes de version » du menu d'administration.</summary>
public class ChangelogPanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new ChangelogAdminModule() });
private static async Task<(TestApi Api, TempFolder Folder, FakeDialogs Dialogs, AdminShellViewModel Shell, ChangelogPanelViewModel Page)>
OpenAsync(string? manifest)
{
var api = await TestApi.StartAsync(manifest is null
? null
: new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
AdminTestKit.RegisterOwner(api, signer);
signer.Dispose();
var dialogs = new FakeDialogs();
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(ChangelogPanelViewModel.PanelId);
await workspace.Activation;
return (api, folder, dialogs, shell, Assert.IsType<ChangelogPanelViewModel>(workspace.SelectedPanel!.ViewModel));
}
private static GameManifest ReadManifest(string path) =>
JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(path), JsonDefaults.Options)!;
[Fact]
public async Task Notes_are_edited_previewed_checked_and_saved_version_by_version()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
await using var _api = api;
using var _folder = folder;
await using var _shell = shell;
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal(new[] { "1.1", "1.0" }, page.Versions.Select(v => v.Version));
Assert.StartsWith("Version publiée : 1.1 · 2 versions", page.ManifestText);
Assert.False(page.HasSeveralGames);
Assert.False(page.HasChanges);
Assert.False(page.SaveCommand.CanExecute(null));
// Version 1.1 : titre, nouvel élément avec étiquette et sous-points ; aperçu à jour.
var v11 = page.SelectedVersion!;
Assert.Equal("1.1", v11.Version);
v11.Title = "Version 1.1 — corrigée";
var section = Assert.Single(v11.Sections);
section.AddChangeCommand.Execute(null);
var item = section.Items[^1];
item.Text = "Les portes se ferment correctement.";
item.Tag = "CORRIGÉ";
item.Details = "salle 2\n\nsalle 3\n";
Assert.True(v11.IsDirty);
Assert.True(page.HasChanges);
Assert.Equal("Version 1.1 — corrigée", v11.Preview.Title);
Assert.Equal(new[] { "salle 2", "salle 3" }, v11.Preview.Sections[0].Items[1].Details);
Assert.True(section.Items[1].MoveUpCommand.CanExecute(null));
Assert.False(section.Items[1].MoveDownCommand.CanExecute(null));
// Version 1.0 (ancien format : liste à plat) : présentée comme une section sans titre.
var v10 = page.Versions[1];
var legacy = Assert.Single(v10.Sections);
Assert.Equal("", legacy.Title);
Assert.Equal("Sortie du niveau 1.", Assert.Single(legacy.Items).Text);
legacy.Items[0].Text = "Sortie du niveau 1 « L'école ».";
// Limite dépassée : signalée, rien n'est envoyé.
item.Tag = new string('X', ChangelogLimits.MaxTagLength + 1);
Assert.True(v11.HasLimitErrors);
await page.SaveCommand.ExecuteAsync();
Assert.Contains(dialogs.Messages, m => m.StartsWith("Notes de la version 1.1 à corriger"));
Assert.Equal("Version 1.1", ReadManifest(manifest).Changelog[0].Title);
item.Tag = "CORRIGÉ";
await page.SaveCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("1.1, 1.0", page.StatusMessage);
Assert.False(page.HasChanges);
Assert.False(v11.IsDirty);
Assert.False(v10.IsDirty);
var saved = ReadManifest(manifest);
Assert.Equal("Version 1.1 — corrigée", saved.Changelog[0].Title);
Assert.Equal("CORRIGÉ", saved.Changelog[0].Sections[0].Items[1].Tag);
Assert.Equal("Sortie du niveau 1 « L'école ».", saved.Changelog[1].Sections[0].Items[0].Text);
Assert.Contains("Sortie du niveau 1 « L'école ».", saved.Changelog[1].Changes); // anciens launchers
Assert.Equal("1.1", saved.Version);
saved.Validate();
}
[Fact]
public async Task A_manifest_changed_on_the_server_is_reported_and_reloaded()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
await using var _api = api;
using var _folder = folder;
await using var _shell = shell;
File.WriteAllText(manifest, File.ReadAllText(manifest).Replace("Première version", "Première version (WinSCP)"));
page.SelectedVersion!.Title = "Ma correction";
await page.SaveCommand.ExecuteAsync();
Assert.True(page.HasConflict);
Assert.Contains(dialogs.Messages, m => m.StartsWith("Manifest modifié entre-temps"));
Assert.DoesNotContain("Ma correction", File.ReadAllText(manifest));
// Recharger : confirmation (modifications perdues), puis le manifest publié est affiché.
dialogs.ConfirmAnswer = false;
await page.ReloadCommand.ExecuteAsync();
Assert.True(page.HasChanges);
dialogs.ConfirmAnswer = true;
await page.ReloadCommand.ExecuteAsync();
Assert.False(page.HasConflict);
Assert.False(page.HasChanges);
Assert.Equal("1.1", page.SelectedVersion!.Version); // même version resélectionnée
Assert.Equal("Première version (WinSCP)", page.Versions[1].Title);
}
[Fact]
public async Task Removing_a_section_with_items_asks_first()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
await using var _api = api;
using var _folder = folder;
await using var _shell = shell;
var version = page.SelectedVersion!;
version.AddSectionCommand.Execute(null);
Assert.Equal(2, version.Sections.Count);
version.Sections[1].RemoveCommand.Execute(null); // vide : retirée sans question
Assert.Single(version.Sections);
Assert.Empty(dialogs.Confirmations);
dialogs.ConfirmAnswer = false;
version.Sections[0].RemoveCommand.Execute(null);
Assert.Single(version.Sections);
Assert.Single(dialogs.Confirmations);
dialogs.ConfirmAnswer = true;
version.Sections[0].RemoveCommand.Execute(null);
Assert.Empty(version.Sections);
Assert.True(version.IsDirty);
}
[Fact]
public async Task A_game_without_configured_manifest_shows_why()
{
var (api, folder, _, shell, page) = await OpenAsync(manifest: null);
await using var _api = api;
using var _folder = folder;
await using var _shell = shell;
Assert.True(page.HasError);
Assert.Contains("non configuré", page.ErrorMessage);
Assert.False(page.HasVersions);
Assert.False(page.SaveCommand.CanExecute(null));
}
}
@@ -0,0 +1,238 @@
using System.Collections.Concurrent;
using System.Text.Json.Nodes;
using System.Windows.Input;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Mvvm;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Publishing;
using GameLauncher.Tests.Server;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Publier une version » du menu d'administration.</summary>
public class PublishPanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new PublishAdminModule() });
[Fact]
public async Task The_page_checks_the_folder_warns_and_publishes()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = PublishingTests.ReleaseFolder.Create(version: "1.1", smallFiles: 4, bigFileBytes: 3 * 1024 * 1024, archiveBytes: 5000);
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.Equal("Version en ligne : 1.1", page.PublishedText);
Assert.False(page.PublishCommand.CanExecute(null));
// Dossier choisi : contrôlé ; même numéro que la version en ligne → avertissement.
dialogs.NextOpenFile = release.ManifestPath;
await page.ChooseCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.HasRelease);
Assert.Equal("Version 1.1", page.VersionText);
Assert.Contains(page.Warnings, w => w.Contains("déjà en ligne"));
Assert.True(page.PublishCommand.CanExecute(null));
// Refus de la confirmation : rien n'est envoyé.
dialogs.ConfirmAnswer = false;
await page.PublishCommand.ExecuteAsync();
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
dialogs.ConfirmAnswer = true;
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Version 1.1 publiée", page.StatusMessage);
Assert.False(page.IsPublishing);
Assert.False(page.HasInterrupted);
Assert.Equal("Nouveautés.", JsonNode.Parse(File.ReadAllText(manifest))!["changelog"]![0]!["changes"]![0]!.GetValue<string>());
// Dossier incomplet : message clair.
File.Delete(Path.Combine(release.Folder, "EZR-1.1.zip"));
await page.RecheckCommand.ExecuteAsync();
Assert.True(page.HasError);
Assert.Contains("Archive absente", page.ErrorMessage);
Assert.False(page.HasRelease);
}
[Fact]
public async Task Publishing_from_the_interface_only_notifies_it_from_its_own_thread()
{
// Comme sous WPF : la page vit sur le fil de l'interface, et tout ce qui notifie les boutons
// ou les liaisons doit s'y exécuter (sinon « un autre thread en est propriétaire »).
var manifest = ChangelogModuleTests.PublishedManifest();
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 4, bigFileBytes: 3 * 1024 * 1024, archiveBytes: 5000);
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 { ConfirmAnswer = true, NextOpenFile = release.ManifestPath };
using var ui = new UiThread();
var offThread = new ConcurrentQueue<string>();
await ui.RunAsync(async () =>
{
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);
page.PropertyChanged += (_, e) => { if (!ui.IsCurrent) offThread.Enqueue($"PropertyChanged({e.PropertyName})"); };
foreach (var (name, command) in new (string, ICommand)[]
{
("Choose", page.ChooseCommand), ("Recheck", page.RecheckCommand), ("Publish", page.PublishCommand),
("Cancel", page.CancelCommand), ("Abandon", page.AbandonCommand)
})
command.CanExecuteChanged += (_, _) => { if (!ui.IsCurrent) offThread.Enqueue($"{name}.CanExecuteChanged"); };
await page.ChooseCommand.ExecuteAsync();
Assert.True(page.HasRelease, page.ErrorMessage);
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("Version 1.2 publiée", page.StatusMessage);
Assert.False(page.HasInterrupted);
});
Assert.Empty(offThread);
Assert.Empty(ui.Errors);
}
[Fact]
public async Task The_interrupted_banner_only_appears_once_the_upload_has_stopped()
{
// Limite des morceaux à 2 par minute : manifest, lot de fichiers, puis l'archive est refusée (429 +
// Retry-After) et l'envoi attend ; la page affiche la pause, puis l'administrateur interrompt l'envoi
// pendant l'attente. (Petits envois : sans nginx devant, Kestrel coupe la connexion au lieu de répondre
// 429 quand il refuse un gros corps qu'il n'a pas lu.)
var manifest = ChangelogModuleTests.PublishedManifest();
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 4, bigFileBytes: 100_000, archiveBytes: 5000);
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Games:EZR:ManifestFile"] = manifest,
["LauncherApi:Limits:PublicationUploadsPerMinute"] = "2"
});
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs { ConfirmAnswer = true, NextOpenFile = release.ManifestPath };
using var ui = new UiThread();
await ui.RunAsync(async () =>
{
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);
await page.ChooseCommand.ExecuteAsync();
Assert.True(page.HasRelease, page.ErrorMessage);
var bannerDuringUpload = false;
string? pause = null;
page.PropertyChanged += (_, _) =>
{
if (page.IsPublishing && page.HasInterrupted) bannerDuringUpload = true;
if (pause is null && page.IsPublishing && page.ProgressText?.Contains("reprise dans") == true)
{
pause = page.ProgressText;
page.CancelCommand.Execute(null);
}
};
await page.PublishCommand.ExecuteAsync();
Assert.False(bannerDuringUpload);
Assert.NotNull(pause);
Assert.Contains("limite de requêtes du serveur atteinte", pause);
Assert.False(page.HasError, page.ErrorMessage);
Assert.False(page.IsPublishing);
Assert.True(page.HasInterrupted); // arrêté : « Publier » reprendra, « Abandonner » nettoie
Assert.Contains("version 1.2", page.InterruptedText);
Assert.Equal("Envoi interrompu.", page.ProgressText);
await page.AbandonCommand.ExecuteAsync();
Assert.False(page.HasInterrupted);
});
Assert.Empty(ui.Errors);
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
}
[Fact]
public async Task A_command_raised_from_a_background_thread_notifies_on_the_interface_thread()
{
using var ui = new UiThread();
RelayCommand? command = null;
AsyncRelayCommand? asyncCommand = null;
await ui.RunAsync(() =>
{
command = new RelayCommand(() => { });
asyncCommand = new AsyncRelayCommand(() => Task.CompletedTask);
return Task.CompletedTask;
});
var threads = new ConcurrentQueue<int>();
var both = new CountdownEvent(2);
command!.CanExecuteChanged += (_, _) => { threads.Enqueue(Environment.CurrentManagedThreadId); both.Signal(); };
asyncCommand!.CanExecuteChanged += (_, _) => { threads.Enqueue(Environment.CurrentManagedThreadId); both.Signal(); };
await Task.Run(() =>
{
command.RaiseCanExecuteChanged();
asyncCommand.RaiseCanExecuteChanged();
});
Assert.True(both.Wait(TimeSpan.FromSeconds(10)));
Assert.All(threads, id => Assert.Equal(ui.ThreadId, id));
// Hors de toute interface (tests, services) : notification directe, comme avant.
var direct = new RelayCommand(() => { });
var raised = false;
direct.CanExecuteChanged += (_, _) => raised = true;
direct.RaiseCanExecuteChanged();
Assert.True(raised);
}
[Fact]
public async Task A_long_operation_postpones_the_inactivity_lock()
{
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);
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs()));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
using (workspace.BeginLongOperation())
{
for (var i = 0; i < 6; i++)
{
api.Time.Advance(TimeSpan.FromMinutes(4));
await workspace.Guard.TickAsync();
}
Assert.Same(workspace, shell.Screen); // 24 minutes d'envoi sans souris ni clavier : pas de verrouillage
}
api.Time.Advance(TimeSpan.FromMinutes(11));
await workspace.Guard.TickAsync();
Assert.IsType<LoginViewModel>(shell.Screen); // 10 minutes après la fin de l'opération : verrouillé
}
}
@@ -0,0 +1,116 @@
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Services;
using GameLauncher.Core.Utils;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Server;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace GameLauncher.Tests.Changelog;
/// <summary>ManifestBuilder --remote-manifest et bornes communes des notes de version.</summary>
public class ChangelogHistoryTests
{
[Fact]
public void New_notes_go_first_and_a_republished_version_replaces_its_old_notes()
{
var history = new List<ChangelogEntry?>
{
new() { Version = "1.1", Title = "Corrigé en ligne" },
null,
new() { Version = "1.0", Title = "Première version" }
};
var republished = ChangelogHistory.Build(history, new ChangelogEntry { Version = "1.1", Title = "Notes de notes.txt" });
Assert.Equal(new[] { "1.1", "1.0" }, republished.Select(e => e.Version));
Assert.Equal("Notes de notes.txt", republished[0].Title);
var next = ChangelogHistory.Build(history, new ChangelogEntry { Version = "1.2", Title = "Nouveautés" });
Assert.Equal(new[] { "1.2", "1.1", "1.0" }, next.Select(e => e.Version));
Assert.Equal("Corrigé en ligne", next[1].Title); // correction en ligne conservée
var withoutNotes = ChangelogHistory.Build(history, new ChangelogEntry { Version = "1.3" });
Assert.Equal(new[] { "1.1", "1.0" }, withoutNotes.Select(e => e.Version));
// Version republiée sans notes.txt : ses notes (corrigées en ligne) sont gardées.
var sameWithoutNotes = ChangelogHistory.Build(history, new ChangelogEntry { Version = "1.1" });
Assert.Equal("Corrigé en ligne", sameWithoutNotes[0].Title);
}
[Fact]
public async Task The_published_manifest_is_read_and_a_missing_one_means_first_publication()
{
var manifest = File.ReadAllText(ChangelogModuleTests.PublishedManifest());
var port = TestApi.FreePort();
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
builder.Logging.ClearProviders();
await using var app = builder.Build();
app.MapGet("/exovivalzombie/manifest.json", () => Results.Text(manifest, "application/json"));
app.MapGet("/casse/manifest.json", () => Results.Text("{ pas du json", "application/json"));
await app.StartAsync();
using var http = new HttpDownloader("GameLauncher.Tests");
var root = new Uri($"http://127.0.0.1:{port}/");
var published = await ChangelogHistory.FetchPublishedAsync(http, new Uri(root, "exovivalzombie/manifest.json"));
Assert.Equal("EZR", published!.GameId);
Assert.Equal(new[] { "1.1", "1.0" }, published.Changelog.Select(e => e.Version));
Assert.Null(await ChangelogHistory.FetchPublishedAsync(http, new Uri(root, "absent/manifest.json")));
await Assert.ThrowsAsync<InvalidDataException>(() => ChangelogHistory.FetchPublishedAsync(http, new Uri(root, "casse/manifest.json")));
await Assert.ThrowsAsync<ArgumentException>(() => ChangelogHistory.FetchPublishedAsync(http, new Uri("http://launcher.example/m.json")));
}
[Fact]
public void Limits_are_reported_without_truncating()
{
var entry = new ChangelogEntry
{
Version = "1.1",
Title = new string('t', ChangelogLimits.MaxTitleLength + 1),
Sections = new()
{
new()
{
Items = new()
{
new() { Text = "ok", Tag = new string('T', ChangelogLimits.MaxTagLength + 1) },
new() { Text = "ok", Details = Enumerable.Repeat("d", ChangelogLimits.MaxDetailsPerItem + 1).ToList() }
}
}
}
};
var errors = ChangelogLimits.Check(entry);
Assert.Equal(3, errors.Count);
Assert.Contains(errors, e => e.StartsWith("Titre"));
Assert.Contains(errors, e => e.Contains("élément 1 (étiquette)"));
Assert.Contains(errors, e => e.Contains("sous-points"));
Assert.Equal(ChangelogLimits.MaxTitleLength + 1, entry.Title!.Length); // rien n'est modifié
// Un texte tronqué par Normalize (ManifestBuilder, launcher) reste dans les limites de Check.
var truncated = ChangelogLimits.Normalize(new[] { entry }).Single();
Assert.Equal(ChangelogLimits.MaxTitleLength, truncated.Title!.Length);
Assert.EndsWith("…", truncated.Title);
Assert.DoesNotContain(ChangelogLimits.Check(truncated), e => e.StartsWith("Titre"));
Assert.Empty(ChangelogLimits.Check(new ChangelogEntry { Version = "1", Sections = new() { null!, new() { Items = new() { null! } } } }));
Assert.True(ChangelogLimits.IsEditableVersion("1.0.3-beta_2"));
Assert.False(ChangelogLimits.IsEditableVersion("1.0/../x"));
Assert.False(ChangelogLimits.IsEditableVersion("1.0+build"));
Assert.False(ChangelogLimits.IsEditableVersion(""));
}
[Fact]
public void Changelog_model_keeps_its_json_format_after_moving_to_contracts()
{
var json = """{ "version": "1.0", "date": "2026-09-01T12:00:00+02:00", "sections": [ { "title": "A", "items": [ { "type": "comment", "text": "c" } ] } ], "changes": [] }""";
var entry = JsonSerializer.Deserialize<ChangelogEntry>(json, JsonDefaults.Options)!;
Assert.True(entry.Sections[0].Items[0].IsComment);
Assert.Equal("1 septembre 2026", entry.DateText);
Assert.DoesNotContain("dateText", JsonSerializer.Serialize(entry, JsonDefaults.Options));
}
}
@@ -69,6 +69,7 @@ public sealed class TestApi : IAsyncDisposable
["LauncherApi:DataDirectory"] = dataDirectory,
["LauncherApi:RequireHttps"] = "false",
["LauncherApi:Limits:RequestsPerMinute"] = "100000",
["LauncherApi:Limits:PublicationUploadsPerMinute"] = "100000",
["LauncherApi:Limits:AuthPerMinute"] = "100000",
["LauncherApi:Limits:EnrollmentsPerHour"] = "100000"
};
@@ -0,0 +1,110 @@
using System.Collections.Concurrent;
namespace GameLauncher.Tests.Infrastructure;
/// <summary>
/// Fil « interface » de test : un seul thread avec son SynchronizationContext, comme le Dispatcher WPF.
/// Permet de vérifier qu'un ViewModel ne notifie l'interface que depuis ce fil.
/// </summary>
public sealed class UiThread : IDisposable
{
private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
private readonly ConcurrentQueue<Exception> _errors = new();
public UiThread()
{
using var ready = new ManualResetEventSlim();
var thread = new Thread(() =>
{
SynchronizationContext.SetSynchronizationContext(new Context(this));
ThreadId = Environment.CurrentManagedThreadId;
ready.Set();
foreach (var (callback, state) in _queue.GetConsumingEnumerable())
{
try
{
callback(state);
}
catch (Exception ex)
{
_errors.Enqueue(ex); // comme une exception non gérée du Dispatcher
}
}
}) { IsBackground = true, Name = "UI (tests)" };
thread.Start();
ready.Wait();
}
public int ThreadId { get; private set; }
/// <summary>Vrai si l'appelant est sur le fil « interface ».</summary>
public bool IsCurrent => Environment.CurrentManagedThreadId == ThreadId;
/// <summary>Exceptions levées par les rappels exécutés sur le fil « interface ».</summary>
public IReadOnlyCollection<Exception> Errors => _errors;
/// <summary>Exécute <paramref name="action"/> sur le fil « interface » (ses <c>await</c> y reviennent).</summary>
public Task RunAsync(Func<Task> action)
{
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Post(async _ =>
{
try
{
await action();
done.SetResult();
}
catch (Exception ex)
{
done.SetException(ex);
}
}, null);
return done.Task;
}
private void Post(SendOrPostCallback callback, object? state)
{
try
{
_queue.Add((callback, state));
}
catch (InvalidOperationException)
{
// fil arrêté (fin du test) : rappel ignoré
}
}
public void Dispose() => _queue.CompleteAdding();
private sealed class Context : SynchronizationContext
{
private readonly UiThread _ui;
public Context(UiThread ui) => _ui = ui;
public override void Post(SendOrPostCallback d, object? state) => _ui.Post(d, state);
public override void Send(SendOrPostCallback d, object? state)
{
if (_ui.IsCurrent)
{
d(state);
return;
}
using var done = new ManualResetEventSlim();
_ui.Post(s =>
{
try
{
d(s);
}
finally
{
done.Set();
}
}, state);
done.Wait();
}
public override SynchronizationContext CreateCopy() => this;
}
}
@@ -0,0 +1,497 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Server.Storage;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Server;
namespace GameLauncher.Tests.Publishing;
/// <summary>Publication d'une version depuis le menu d'administration (envoi, reprise, mise en ligne).</summary>
public class PublishingTests
{
/// <summary>Dossier produit par ManifestBuilder (--output) : manifest, archive, objects/.</summary>
public sealed class ReleaseFolder
{
public string Folder { get; } = Path.Combine(Path.GetTempPath(), "launcher-publish-tests", Guid.NewGuid().ToString("N"));
public string ManifestPath => Path.Combine(Folder, "manifest.json");
public GameManifest Manifest { get; private set; } = new();
public List<(string Path, byte[] Data)> Files { get; } = new();
public byte[] Archive { get; private set; } = Array.Empty<byte>();
public static ReleaseFolder Create(string version = "1.2", int smallFiles = 30, int bigFileBytes = 5 * 1024 * 1024,
int archiveBytes = 9 * 1024 * 1024 + 123, byte[]? shared = null)
{
var release = new ReleaseFolder();
Directory.CreateDirectory(release.Folder);
release.Files.Add(("Exovival Zombie Reborn.exe", RandomNumberGenerator.GetBytes(40_000)));
for (var i = 0; i < smallFiles; i++) release.Files.Add(($"Data/level{i}.pak", RandomNumberGenerator.GetBytes(1000 + i * 137)));
release.Files.Add(("Data/empty.txt", Array.Empty<byte>()));
release.Files.Add(("Data/big.assets", RandomNumberGenerator.GetBytes(bigFileBytes)));
if (shared is not null) release.Files.Add(("Data/shared.pak", shared));
release.Archive = RandomNumberGenerator.GetBytes(archiveBytes);
release.Write(version);
return release;
}
public void Write(string version, Action<GameManifest>? edit = null)
{
foreach (var (_, data) in Files)
{
var path = Path.Combine(Folder, ManifestCheck.ObjectPath(Sha(data)));
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, data);
}
var archiveName = $"EZR-{version}.zip";
File.WriteAllBytes(Path.Combine(Folder, archiveName), Archive);
Manifest = new GameManifest
{
GameId = "EZR",
Version = version,
ReleaseDate = DateTimeOffset.Now,
Executable = "Exovival Zombie Reborn.exe",
Archive = new ArchiveInfo { Url = archiveName, Size = Archive.Length, Sha256 = Sha(Archive) },
Files = Files.Select(f => new ManifestFile { Path = f.Path, Size = f.Data.Length, Sha256 = Sha(f.Data) }).ToList(),
Changelog = new List<ChangelogEntry>
{
new() { Version = version, Date = DateTimeOffset.Now, Title = $"Version {version}", Changes = new() { "Nouveautés." } },
new() { Version = "1.1", Title = "Notes locales de la 1.1 (pas celles du serveur)", Changes = new() { "ancien" } }
}
};
edit?.Invoke(Manifest);
File.WriteAllText(ManifestPath, JsonSerializer.Serialize(Manifest, JsonDefaults.Options));
}
}
public static string Sha(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
private static Dictionary<string, string?> Settings(string manifest) => new() { ["LauncherApi:Games:EZR:ManifestFile"] = manifest };
/// <summary>Avancement transmis immédiatement (Progress&lt;T&gt; passerait par le pool de threads).</summary>
private sealed class Sync<T> : IProgress<T>
{
private readonly Action<T> _action;
public Sync(Action<T> action) => _action = action;
public void Report(T value) => _action(value);
}
[Fact]
public async Task A_version_is_uploaded_and_put_online_with_the_server_notes_history()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var gameDirectory = Path.GetDirectoryName(manifest)!;
// Un fichier est déjà sur le serveur (publication précédente) : il n'est pas renvoyé.
var shared = RandomNumberGenerator.GetBytes(3000);
var sharedPath = Path.Combine(gameDirectory, ManifestCheck.ObjectPath(Sha(shared)));
Directory.CreateDirectory(Path.GetDirectoryName(sharedPath)!);
File.WriteAllBytes(sharedPath, shared);
var release = ReleaseFolder.Create(shared: shared);
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
Assert.Equal("1.2", local.Summary.Version);
Assert.Equal("Version 1.2", local.Summary.NotesTitle);
var reports = new List<UploadProgress>();
PublicationState? started = null;
var result = await ReleaseUploader.PublishAsync(owner, "EZR", local, new Sync<UploadProgress>(reports.Add), new Sync<PublicationState>(s => started = s), default);
Assert.Equal("1.2", result.Version);
Assert.Equal("1.1", result.PreviousVersion);
Assert.Equal(release.Files.Count - 1, result.ObjectsAdded); // le fichier partagé était déjà là
Assert.True(result.ArchiveAdded);
Assert.Equal("1.1", started!.PublishedVersion);
Assert.Equal(reports[^1].Total, reports.Where(r => r.Step.StartsWith("Envoi")).Max(r => r.Done));
Assert.Contains(reports, r => r.Step == "Mise en ligne");
// Fichiers en ligne, identiques à ceux du dossier ; aucun dépôt laissé.
foreach (var (_, data) in release.Files)
Assert.Equal(data, File.ReadAllBytes(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(Sha(data)))));
Assert.Equal(release.Archive, File.ReadAllBytes(Path.Combine(gameDirectory, "EZR-1.2.zip")));
Assert.Empty(Directory.GetDirectories(gameDirectory, ".publish-*"));
Assert.Empty(Directory.GetFiles(Path.Combine(api.DataDirectory, "publications")).Where(f => !f.EndsWith(".done.json")));
if (!OperatingSystem.IsWindows())
{
var expected = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead; // comme le manifest en ligne
Assert.Equal(expected, File.GetUnixFileMode(Path.Combine(gameDirectory, "EZR-1.2.zip")));
Assert.True(File.GetUnixFileMode(Path.GetDirectoryName(Path.Combine(gameDirectory, ManifestCheck.ObjectPath(Sha(release.Files[0].Data))))!)
.HasFlag(UnixFileMode.GroupExecute)); // dossiers objects/xx traversables par nginx
}
// Manifest : celui de la version, avec l'historique des notes du SERVEUR (pas les notes locales de la 1.1).
var online = JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(manifest), JsonDefaults.Options)!;
online.Validate();
Assert.Equal("1.2", online.Version);
Assert.Equal(new[] { "1.2", "1.1", "1.0" }, online.Changelog.Select(e => e.Version));
Assert.Equal("Version 1.1", online.Changelog[1].Title);
Assert.Equal(release.Manifest.Files.Count, online.Files.Count);
var audit = await owner.GetAuditAsync();
Assert.Contains(audit.Entries, e => e.Action == "release.start" && e.Target == "EZR 1.2");
Assert.Contains(audit.Entries, e => e.Action == "release.publish" && e.Target == "EZR 1.2" && e.Details!.Contains("1.1"));
}
[Fact]
public async Task Notes_edited_online_during_the_upload_are_kept_and_a_republication_sends_nothing_again()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = ReleaseFolder.Create(smallFiles: 3, bigFileBytes: 10, archiveBytes: 2000);
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
var started = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
});
// Correction des notes de la 1.1 (page Notes de version) pendant l'envoi : permise, et conservée.
var notes = await owner.GetChangelogAsync("EZR");
notes.Entries[0].Title = "1.1 corrigée en ligne";
await owner.SaveChangelogEntryAsync("EZR", notes.Entries[0], notes.ManifestSha256);
var result = await ReleaseUploader.PublishAsync(owner, "EZR", local, null, null, default);
var online = JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(manifest), JsonDefaults.Options)!;
Assert.Equal("1.1 corrigée en ligne", online.Changelog[1].Title);
// Réponse de mise en ligne perdue : le nouvel essai renvoie le même résultat, sans republier.
var again = await owner.CommitPublicationAsync(started.PublicationId);
Assert.Equal(result.ManifestSha256, again.ManifestSha256);
Assert.Single((await owner.GetAuditAsync()).Entries, e => e.Action == "release.publish");
// Même version republiée : fichiers et archive déjà en ligne, rien n'est renvoyé.
var republished = await ReleaseUploader.PublishAsync(owner, "EZR", local, null, null, default);
Assert.Equal(0, republished.ObjectsAdded);
Assert.False(republished.ArchiveAdded);
Assert.Equal("1.2", republished.PreviousVersion);
}
[Fact]
public async Task An_abandoned_publication_can_be_taken_over_after_30_minutes()
{
var manifest = ChangelogModuleTests.PublishedManifest();
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var first = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = new string('a', 64), ManifestSize = 100
});
var (otherKey, _) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Admin);
using var __ = otherKey;
api.Time.Advance(TimeSpan.FromMinutes(31));
await using var other = await api.LoginAsync(otherKey);
var second = await other.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.3", ManifestSha256 = new string('b', 64), ManifestSize = 100
});
Assert.NotEqual(first.PublicationId, second.PublicationId);
Assert.Single(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
await using var ownerAgain = await api.LoginAsync(ownerKey); // session précédente expirée (31 min)
var audit = await ownerAgain.GetAuditAsync();
Assert.Contains(audit.Entries, e => e.Action == "release.cancel" && e.Target == "EZR 1.2");
}
[Fact]
public async Task An_interrupted_upload_resumes_where_it_stopped()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = ReleaseFolder.Create();
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
// Interruption pendant l'envoi de l'archive (après son premier morceau).
using var cancel = new CancellationTokenSource();
var progress = new Sync<UploadProgress>(p =>
{
if (p.Step.StartsWith("Envoi de l'archive") && p.Done >= p.Total - release.Archive.Length + PublishingLimits.ChunkBytes) cancel.Cancel();
});
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ReleaseUploader.PublishAsync(owner, "EZR", local, progress, null, cancel.Token));
var directory = Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*").Single();
var id = Path.GetFileName(directory)[".publish-".Length..];
var state = await owner.GetPublicationAsync(id);
Assert.True(state.ManifestAccepted);
Assert.Empty(state.PendingObjects);
Assert.False(state.ArchiveComplete);
Assert.Equal(PublishingLimits.ChunkBytes, state.ArchiveReceived);
Assert.Equal("1.1", JsonNode.Parse(File.ReadAllText(manifest))!["version"]!.GetValue<string>()); // rien en ligne
// Reprise : seul le reste de l'archive est envoyé.
var reports = new List<UploadProgress>();
PublicationState? resumed = null;
var result = await ReleaseUploader.PublishAsync(owner, "EZR", local, new Sync<UploadProgress>(reports.Add), new Sync<PublicationState>(s => resumed = s), default);
Assert.True(resumed!.Resumed);
Assert.Equal(id, resumed.PublicationId);
Assert.Equal(release.Archive.Length - PublishingLimits.ChunkBytes, reports.Max(r => r.Total));
Assert.Equal("1.2", result.Version);
Assert.Equal(release.Archive, File.ReadAllBytes(Path.Combine(Path.GetDirectoryName(manifest)!, "EZR-1.2.zip")));
}
[Fact]
public async Task A_version_published_during_the_upload_is_never_overwritten()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = ReleaseFolder.Create(smallFiles: 3, bigFileBytes: 10, archiveBytes: 1000);
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
});
// Une version 1.5 est envoyée avec WinSCP pendant ce temps.
var winscp = File.ReadAllText(manifest).Replace("\"version\": \"1.1\"", "\"version\": \"1.5\"");
File.WriteAllText(manifest, winscp);
var refused = await Assert.ThrowsAsync<ApiException>(() => ReleaseUploader.PublishAsync(owner, "EZR", local, null, null, default));
Assert.Equal(409, refused.Status);
Assert.Contains("1.5", refused.Message);
Assert.Equal(winscp, File.ReadAllText(manifest));
Assert.False(File.Exists(Path.Combine(Path.GetDirectoryName(manifest)!, "EZR-1.2.zip")));
}
[Fact]
public async Task Unsafe_or_inconsistent_publications_are_refused()
{
var manifest = ChangelogModuleTests.PublishedManifest();
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
// Manifest dangereux, contrôlé par le launcher ET par le serveur.
var release = ReleaseFolder.Create(smallFiles: 2, bigFileBytes: 10, archiveBytes: 100);
release.Write("1.2", m =>
{
m.Archive.Url = "../../etc/EZR.zip";
m.Files.Add(new ManifestFile { Path = "../outside.dll", Size = 1, Sha256 = new string('c', 64) });
});
var local = Assert.Throws<ReleaseCheckException>(() => ReleaseUploader.Inspect(release.ManifestPath, "EZR"));
Assert.Contains("../outside.dll", local.Message);
Assert.Contains("simple nom de fichier", local.Message);
var bytes = File.ReadAllBytes(release.ManifestPath);
var state = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = Sha(bytes), ManifestSize = bytes.Length
});
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.UploadManifestAsync(state.PublicationId, 0, bytes));
Assert.Equal(400, refused.Status);
Assert.Contains("Manifest refusé", refused.Message);
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*")); // publication abandonnée
// Fichier modifié après la génération : refusé avant la mise en ligne.
var changed = ReleaseFolder.Create(smallFiles: 2, bigFileBytes: 10, archiveBytes: 100);
var victim = Path.Combine(changed.Folder, ManifestCheck.ObjectPath(Sha(changed.Files[1].Data)));
var tampered = File.ReadAllBytes(victim);
tampered[0] ^= 0xFF;
File.WriteAllBytes(victim, tampered);
var inspected = ReleaseUploader.Inspect(changed.ManifestPath, "EZR"); // même taille : vu seulement à l'envoi
var error = await Assert.ThrowsAsync<ReleaseCheckException>(() => ReleaseUploader.PublishAsync(owner, "EZR", inspected, null, null, default));
Assert.Contains("a changé depuis la génération", error.Message);
// Le serveur vérifie aussi chaque objet reçu.
var publication = (await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = inspected.ManifestSha256, ManifestSize = inspected.ManifestBytes.Length
}));
await owner.UploadManifestAsync(publication.PublicationId, 0, inspected.ManifestBytes);
var sha = Sha(changed.Files[1].Data);
var bad = await Assert.ThrowsAsync<ApiException>(() =>
owner.UploadObjectBatchAsync(publication.PublicationId, ObjectBatch.Write(new[] { (sha, tampered) })));
Assert.Equal(400, bad.Status);
var notExpected = await Assert.ThrowsAsync<ApiException>(() =>
owner.UploadObjectBatchAsync(publication.PublicationId, ObjectBatch.Write(new[] { (Sha(new byte[] { 1 }), new byte[] { 1 }) })));
Assert.Contains("pas attendu", notExpected.Message);
var offset = await Assert.ThrowsAsync<ApiException>(() => owner.UploadArchiveAsync(publication.PublicationId, 50, new byte[10]));
Assert.Equal(ApiErrorCodes.OffsetMismatch, offset.Code);
var incomplete = await Assert.ThrowsAsync<ApiException>(() => owner.CommitPublicationAsync(publication.PublicationId));
Assert.Contains("Envoi incomplet", incomplete.Message);
// Un autre administrateur ne peut ni la poursuivre, ni la remplacer tant qu'elle est active.
var (otherKey, _) = await api.AddAdminAsync(owner, "Paul", AdminRoles.Admin);
using var __ = otherKey;
await using var other = await api.LoginAsync(otherKey);
var forbidden = await Assert.ThrowsAsync<ApiException>(() => other.GetPublicationAsync(publication.PublicationId));
Assert.Equal(403, forbidden.Status);
var busy = await Assert.ThrowsAsync<ApiException>(() => other.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.3", ManifestSha256 = new string('d', 64), ManifestSize = 10
}));
Assert.Equal(ApiErrorCodes.PublicationInProgress, busy.Code);
// Abandon : dépôt supprimé.
await owner.CancelPublicationAsync(publication.PublicationId);
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
var gone = await Assert.ThrowsAsync<ApiException>(() => owner.GetPublicationAsync(publication.PublicationId));
Assert.Equal(404, gone.Status);
}
[Fact]
public async Task Only_upload_routes_accept_large_bodies_and_editors_cannot_publish()
{
var manifest = ChangelogModuleTests.PublishedManifest();
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var release = ReleaseFolder.Create(smallFiles: 1, bigFileBytes: 10, archiveBytes: 9 * 1024 * 1024);
var local = ReleaseUploader.Inspect(release.ManifestPath, "EZR");
var state = await owner.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
});
await owner.UploadManifestAsync(state.PublicationId, 0, local.ManifestBytes);
// 8 Mo : accepté sur une route d'envoi ; au-delà : refusé.
var ok = await owner.UploadArchiveAsync(state.PublicationId, 0, release.Archive.AsSpan(0, 8 * 1024 * 1024).ToArray());
Assert.Equal(8 * 1024 * 1024, ok.Received);
// Requêtes trop grosses : chacune sur sa propre connexion (Kestrel ferme la connexion après un refus).
await using (var oversize = await api.LoginAsync(ownerKey))
{
var tooLarge = await Assert.ThrowsAsync<ApiException>(() =>
oversize.UploadArchiveAsync(state.PublicationId, ok.Received, new byte[(int)PublishingLimits.MaxUploadRequestBytes + 1]));
Assert.True(tooLarge.Status is 413 or 0, tooLarge.Message); // 413, ou connexion coupée par Kestrel pendant l'envoi
}
// Les autres routes gardent la limite de 64 Ko.
await using (var oversize = await api.LoginAsync(ownerKey))
{
var bigJson = await Assert.ThrowsAsync<ApiException>(() => oversize.SaveChangelogEntryAsync("EZR",
new ChangelogEntry { Version = "1.1", Title = new string('x', 70_000) }, new string('a', 64)));
Assert.True(bigJson.Status is 413 or 0, bigJson.Message);
}
// Un éditeur (contenu) ne peut pas publier.
var (editorKey, _) = await api.AddAdminAsync(owner, "Léa", AdminRoles.Editor);
using var __ = editorKey;
await using var editor = await api.LoginAsync(editorKey);
var denied = await Assert.ThrowsAsync<ApiException>(() => editor.StartPublicationAsync("EZR", new PublicationStartRequest
{
Version = "1.2", ManifestSha256 = local.ManifestSha256, ManifestSize = local.ManifestBytes.Length
}));
Assert.Equal(403, denied.Status);
}
[Fact]
public void Manifest_rules_are_shared_by_the_launcher_and_the_server()
{
var release = ReleaseFolder.Create(smallFiles: 1, bigFileBytes: 10, archiveBytes: 10);
var bytes = File.ReadAllBytes(release.ManifestPath);
var errors = new List<string>();
Assert.NotNull(ManifestCheck.Check(bytes, "ezr", "1.2", errors));
Assert.Null(ManifestCheck.Check(bytes, "AUTRE", "1.2", errors));
Assert.Contains(errors, e => e.Contains("AUTRE"));
foreach (var (edit, expected) in new (Action<JsonObject>, string)[]
{
(m => m["objectsBaseUrl"] = "https://ailleurs.example/objects/", "objectsBaseUrl"),
(m => m["version"] = "1.2/../x", "Version"),
(m => m["executable"] = "absent.exe", "exécutable"),
(m => m["files"]![0]!["path"] = "C:/Windows/x.dll", "Chemin non autorisé"),
(m => m["files"]![0]!["sha256"] = "zz", "empreinte"),
(m => m["formatVersion"] = 2, "formatVersion")
})
{
var node = JsonNode.Parse(bytes)!.AsObject();
edit(node);
errors.Clear();
Assert.Null(ManifestCheck.Check(System.Text.Encoding.UTF8.GetBytes(node.ToJsonString()), "EZR", null, errors));
Assert.Contains(errors, e => e.Contains(expected, StringComparison.OrdinalIgnoreCase));
}
}
[Fact]
public async Task Chunk_uploads_have_their_own_rate_limit_and_a_refusal_says_when_to_retry()
{
// Limite générale très basse : un envoi de 7 morceaux la dépasserait. Les morceaux ont leur propre
// limite (haute) : aucune pause pendant l'envoi.
var manifest = ChangelogModuleTests.PublishedManifest();
var release = ReleaseFolder.Create();
var settings = Settings(manifest);
settings["LauncherApi:Limits:RequestsPerMinute"] = "8";
await using var api = await TestApi.StartAsync(settings);
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var reports = new List<UploadProgress>();
var result = await ReleaseUploader.PublishAsync(owner, "EZR", ReleaseUploader.Inspect(release.ManifestPath, "EZR"),
new Sync<UploadProgress>(reports.Add), null, default);
Assert.Equal("1.2", result.Version);
Assert.DoesNotContain(reports, r => r.Step.Contains("reprise dans"));
// La limite générale s'applique toujours au reste de l'API, et le refus annonce le délai (Retry-After).
HttpResponseMessage? limited = null;
for (var i = 0; i < 20 && limited is null; i++)
{
var response = await api.Http.GetAsync(ApiRoutes.Info);
if (response.StatusCode == HttpStatusCode.TooManyRequests) limited = response;
else response.Dispose();
}
Assert.NotNull(limited);
using (limited)
{
Assert.Equal(ApiErrorCodes.RateLimited, (await TestApi.ReadErrorAsync(limited!)).Code);
Assert.InRange(limited!.Headers.RetryAfter!.Delta!.Value, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60));
}
var error = await Assert.ThrowsAsync<ApiException>(() => api.CreateClient().GetInfoAsync());
Assert.Equal(429, error.Status);
Assert.InRange(error.RetryAfter!.Value, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(60));
}
[Fact]
public void After_a_refusal_the_upload_waits_just_as_long_as_needed()
{
static ApiException Limited(TimeSpan? retryAfter) => new(429, ApiErrorCodes.RateLimited, "") { RetryAfter = retryAfter };
// Délai annoncé par le serveur, arrondi à la seconde, entre 1 et 60 s.
Assert.Equal(TimeSpan.FromSeconds(12), ReleaseUploader.Backoff(1, Limited(TimeSpan.FromSeconds(11.2))));
Assert.Equal(TimeSpan.FromSeconds(1), ReleaseUploader.Backoff(1, Limited(TimeSpan.Zero)));
Assert.Equal(TimeSpan.FromSeconds(60), ReleaseUploader.Backoff(1, Limited(TimeSpan.FromMinutes(10))));
// Limite de nginx (pas de délai annoncé) : 1 s, puis le double à chaque refus consécutif, 30 s au plus.
Assert.Equal(new[] { 1, 2, 4, 8, 16, 30, 30, 30 },
Enumerable.Range(1, 8).Select(a => (int)ReleaseUploader.Backoff(a, Limited(null)).TotalSeconds).ToArray());
// Position décalée : reprise immédiate ; coupure réseau : attente croissante.
Assert.Equal(TimeSpan.Zero, ReleaseUploader.Backoff(3, new ApiException(409, ApiErrorCodes.OffsetMismatch, "")));
Assert.Equal(TimeSpan.FromSeconds(8), ReleaseUploader.Backoff(2, new ApiException(0, "unreachable", "")));
}
[Fact]
public void Object_folders_are_readable_by_nginx_without_asking_for_special_bits()
{
// Sur le VPS, systemd refuse au service toute demande de bit setgid (RestrictSUIDSGID=yes) : les dossiers
// sont créés sans bit spécial, le setgid et le groupe venant du dossier parent.
if (OperatingSystem.IsWindows()) return;
var game = Path.GetDirectoryName(ChangelogModuleTests.PublishedManifest())!;
const UnixFileMode special = UnixFileMode.SetUser | UnixFileMode.SetGroup | UnixFileMode.StickyBit;
var permissions = File.GetUnixFileMode(game) & ~special;
var objects = Path.Combine(game, "objects");
PublishedFiles.EnsurePublicDirectory(objects, game, keepSetGid: true);
Assert.Equal(permissions | UnixFileMode.SetGroup, File.GetUnixFileMode(objects)); // ses sous-dossiers auront le groupe de nginx
var leaf = Path.Combine(objects, "ab");
PublishedFiles.EnsurePublicDirectory(leaf, game, keepSetGid: false);
Assert.Equal(permissions, File.GetUnixFileMode(leaf) & ~special);
PublishedFiles.EnsurePublicDirectory(leaf, game, keepSetGid: false); // déjà là : rien ne change
Assert.Equal(permissions, File.GetUnixFileMode(leaf) & ~special);
}
}
@@ -0,0 +1,305 @@
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Server.Hosting;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Server;
/// <summary>Notes de version : modification d'une version existante dans le manifest.json publié.</summary>
public class ChangelogModuleTests
{
private static readonly string Hash = new('a', 64);
/// <summary>Dossier du jeu (comme sur le VPS) avec un manifest écrit par ManifestBuilder.</summary>
public static string PublishedManifest(string gameId = "EZR")
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-changelog-tests", Guid.NewGuid().ToString("N"), "exovivalzombie");
Directory.CreateDirectory(directory);
// Comme sur le VPS : dossier setgid (groupe www-data transmis aux nouveaux fichiers). Déjà hérité si le
// dossier des tests est lui-même setgid (tests lancés sous le filtre de RestrictSUIDSGID=yes).
if (!OperatingSystem.IsWindows() && !File.GetUnixFileMode(directory).HasFlag(UnixFileMode.SetGroup))
File.SetUnixFileMode(directory, File.GetUnixFileMode(directory) | UnixFileMode.SetGroup);
var manifest = new GameManifest
{
GameId = gameId,
Version = "1.1",
ReleaseDate = new DateTimeOffset(2026, 10, 17, 18, 0, 0, TimeSpan.FromHours(2)),
Executable = "Exovival Zombie Reborn.exe",
Archive = new ArchiveInfo { Url = "EZR-1.1.zip", Size = 1234, Sha256 = Hash },
Files = new List<ManifestFile>
{
new() { Path = "Exovival Zombie Reborn.exe", Size = 1000, Sha256 = Hash },
new() { Path = "Data/level1.pak", Size = 234, Sha256 = new string('b', 64) }
},
Changelog = new List<ChangelogEntry>
{
new()
{
Version = "1.1",
Date = new DateTimeOffset(2026, 10, 17, 18, 0, 0, TimeSpan.FromHours(2)),
Title = "Version 1.1",
Subtitle = "Niveau 1 « L'école »",
Sections = new List<ChangelogSection>
{
new()
{
Title = "PROGRESSION",
Items = new List<ChangelogItem> { new() { Text = "La petite clé peut être ramasée.", Tag = "CORRIGÉ" } }
}
},
Changes = new List<string> { "— PROGRESSION —", "CORRIGÉ : La petite clé peut être ramasée." }
},
new()
{
Version = "1.0",
Date = new DateTimeOffset(2026, 9, 1, 12, 0, 0, TimeSpan.FromHours(2)),
Title = "Première version",
Changes = new List<string> { "Sortie du niveau 1." }
}
}
};
var path = Path.Combine(directory, "manifest.json");
File.WriteAllText(path, JsonSerializer.Serialize(manifest, JsonDefaults.Options));
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead);
return path;
}
private static Dictionary<string, string?> Settings(string manifest) => new() { ["LauncherApi:Games:EZR:ManifestFile"] = manifest };
private static JsonObject WithoutChangelog(string path)
{
var root = JsonNode.Parse(File.ReadAllText(path))!.AsObject();
root.Remove("changelog");
return root;
}
[Fact]
public async Task Editing_a_version_changes_only_its_notes_in_the_published_manifest()
{
var manifest = PublishedManifest();
var before = WithoutChangelog(manifest);
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var state = await owner.GetChangelogAsync("ezr");
Assert.Equal("EZR", state.GameId);
Assert.Equal("1.1", state.ManifestVersion);
Assert.Equal(ChangelogStoreHash(manifest), state.ManifestSha256);
Assert.Equal(new[] { "1.1", "1.0" }, state.Entries.Select(e => e.Version));
// Faute corrigée, élément ajouté ; la date envoyée est ignorée (celle du manifest est conservée).
var edited = state.Entries[0];
edited.Date = DateTimeOffset.UnixEpoch;
edited.Sections[0].Items[0].Text = "La petite clé peut être ramassée.";
edited.Sections[0].Items.Add(new ChangelogItem { Text = "Les portes se ferment correctement.", Details = new() { "salle 2", "salle 3" } });
var saved = await owner.SaveChangelogEntryAsync("EZR", edited, state.ManifestSha256);
Assert.NotEqual(state.ManifestSha256, saved.ManifestSha256);
Assert.Equal(ChangelogStoreHash(manifest), saved.ManifestSha256);
var entry = saved.Entries[0];
Assert.Equal("La petite clé peut être ramassée.", entry.Sections[0].Items[0].Text);
Assert.Equal(2, entry.Sections[0].Items.Count);
Assert.Equal(new DateTimeOffset(2026, 10, 17, 18, 0, 0, TimeSpan.FromHours(2)), entry.Date);
var flat = JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(manifest), JsonDefaults.Options)!.Changelog[0].Changes;
Assert.Contains(" · salle 2", flat); // liste à plat régénérée dans le manifest pour les anciens launchers
Assert.Equal("Sortie du niveau 1.", Assert.Single(saved.Entries[1].Changes)); // autre version intacte
// Le reste du manifest est identique : fichiers, archive, version, exécutable…
Assert.True(JsonNode.DeepEquals(before, WithoutChangelog(manifest)));
var reread = JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(manifest), JsonDefaults.Options)!;
reread.Validate();
Assert.Contains("ramassée", File.ReadAllText(manifest)); // accents lisibles, comme ManifestBuilder
if (!OperatingSystem.IsWindows())
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead, File.GetUnixFileMode(manifest));
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(manifest)!, "*.tmp"));
// Ancienne version gardée dans les données privées ; action inscrite dans l'audit.
Assert.Single(Directory.GetFiles(Path.Combine(api.DataDirectory, "manifest-history", "ezr")));
var audit = await owner.GetAuditAsync();
Assert.Contains(audit.Entries, e => e.Action == "changelog.edit" && e.Target == "EZR 1.1");
// Modification fondée sur l'ancienne empreinte : refusée, rien n'est écrasé.
var conflict = await Assert.ThrowsAsync<ApiException>(() => owner.SaveChangelogEntryAsync("EZR", edited, state.ManifestSha256));
Assert.Equal(409, conflict.Status);
Assert.Equal(ApiErrorCodes.RevisionConflict, conflict.Code);
}
[Fact]
public async Task Unknown_fields_and_other_name_casing_are_preserved()
{
var manifest = PublishedManifest();
// Manifest édité à la main : noms en PascalCase, champ inconnu d'une version future de ManifestBuilder.
var root = JsonNode.Parse(File.ReadAllText(manifest))!.AsObject();
var first = root["changelog"]![0]!.AsObject();
var version = first["version"]!.DeepClone();
var date = first["date"]!.DeepClone();
first.Remove("version");
first.Remove("date");
first["Version"] = version;
first["Date"] = date;
first["video"] = "https://www.youtube.com/watch?v=abc";
File.WriteAllText(manifest, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var state = await owner.GetChangelogAsync("EZR");
Assert.Empty(state.Entries[0].Changes); // liste à plat omise : les sections suffisent à l'éditeur
var edited = state.Entries[0];
edited.Title = "Titre corrigé";
await owner.SaveChangelogEntryAsync("EZR", edited, state.ManifestSha256);
var saved = JsonNode.Parse(File.ReadAllText(manifest))!["changelog"]![0]!.AsObject();
Assert.Equal("1.1", saved["Version"]!.GetValue<string>());
Assert.NotNull(saved["Date"]);
Assert.Null(saved["version"]);
Assert.Equal("https://www.youtube.com/watch?v=abc", saved["video"]!.GetValue<string>());
Assert.Equal("Titre corrigé", saved["title"]!.GetValue<string>());
Assert.Equal("1.1", (await owner.GetChangelogAsync("EZR")).Entries[0].Version);
}
[Fact]
public async Task Risky_manifest_replacements_are_refused()
{
if (OperatingSystem.IsWindows()) return;
var manifest = PublishedManifest();
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var state = await owner.GetChangelogAsync("EZR");
var directory = Path.GetDirectoryName(manifest)!;
// Manifest lisible par le groupe seulement (nginx = groupe www-data), dossier sans setgid : refusé.
File.SetUnixFileMode(directory, File.GetUnixFileMode(directory) & ~UnixFileMode.SetGroup);
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.SaveChangelogEntryAsync("EZR", state.Entries[0], state.ManifestSha256));
Assert.Equal(503, refused.Status);
Assert.Contains("setgid", refused.Message);
Assert.DoesNotContain(directory, refused.Message);
// Avec setgid : accepté.
File.SetUnixFileMode(directory, File.GetUnixFileMode(directory) | UnixFileMode.SetGroup);
state = await owner.SaveChangelogEntryAsync("EZR", state.Entries[0], state.ManifestSha256);
// Manifest remplacé par un lien symbolique : jamais remplacé (le lien serait rompu).
var target = Path.Combine(directory, "manifest-1.1.json");
File.Move(manifest, target);
File.CreateSymbolicLink(manifest, target);
var link = await Assert.ThrowsAsync<ApiException>(() => owner.SaveChangelogEntryAsync("EZR", state.Entries[0], state.ManifestSha256));
Assert.Contains("lien symbolique", link.Message);
Assert.NotNull(new FileInfo(manifest).LinkTarget);
}
[Fact]
public async Task A_manifest_republished_in_the_meantime_is_never_overwritten()
{
var manifest = PublishedManifest();
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var state = await owner.GetChangelogAsync("EZR");
// Nouvelle version envoyée par WinSCP pendant l'édition.
var republished = File.ReadAllText(manifest).Replace("\"version\": \"1.1\"", "\"version\": \"1.2\"");
File.WriteAllText(manifest, republished);
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.SaveChangelogEntryAsync("EZR", state.Entries[0], state.ManifestSha256));
Assert.Equal(409, refused.Status);
Assert.Equal(republished, File.ReadAllText(manifest));
}
[Fact]
public async Task Invalid_edits_are_refused()
{
var manifest = PublishedManifest();
await using var api = await TestApi.StartAsync(Settings(manifest));
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var state = await owner.GetChangelogAsync("EZR");
var original = File.ReadAllText(manifest);
async Task Refused(ChangelogEntry entry, int status)
{
var error = await Assert.ThrowsAsync<ApiException>(() => owner.SaveChangelogEntryAsync("EZR", entry, state.ManifestSha256));
Assert.Equal(status, error.Status);
}
await Refused(new ChangelogEntry { Version = "2.0", Title = "Version inexistante" }, 404); // seules les versions existantes
await Refused(new ChangelogEntry { Version = "1.1" }, 400); // notes vides
await Refused(new ChangelogEntry
{
Version = "1.1",
Sections = Enumerable.Range(0, ChangelogLimits.MaxSectionsPerEntry + 1)
.Select(i => new ChangelogSection { Title = $"S{i}", Items = new() { new() { Text = "x" } } }).ToList()
}, 400);
await Refused(new ChangelogEntry { Version = "1.1", Title = new string('x', ChangelogLimits.MaxTitleLength + 1) }, 400);
Assert.Throws<ArgumentException>(() => { _ = owner.SaveChangelogEntryAsync("EZR", new ChangelogEntry { Version = "1.1/../x" }, state.ManifestSha256); });
// Version de l'adresse différente de celle des notes, requête non signée.
var session = await api.RawLoginAsync(ownerKey);
using (var mismatch = await api.SendSignedRawAsync(session, ownerKey, HttpMethod.Put, ApiRoutes.GameChangelogEntry("EZR", "1.0"),
new ChangelogUpdateRequest { BaseSha256 = state.ManifestSha256, Entry = state.Entries[0] }))
Assert.Equal(HttpStatusCode.BadRequest, mismatch.StatusCode);
using (var unsigned = await api.SendSignedRawAsync(session, ownerKey, HttpMethod.Put, ApiRoutes.GameChangelogEntry("EZR", "1.1"),
new ChangelogUpdateRequest { BaseSha256 = state.ManifestSha256, Entry = state.Entries[0] }, includeSignature: false))
{
Assert.Equal(HttpStatusCode.Unauthorized, unsigned.StatusCode);
Assert.Equal(ApiErrorCodes.SignatureRequired, (await TestApi.ReadErrorAsync(unsigned)).Code);
}
Assert.Equal(original, File.ReadAllText(manifest));
}
[Fact]
public async Task Games_without_a_configured_manifest_cannot_be_edited()
{
var other = PublishedManifest(gameId: "DEMO");
await using var api = await TestApi.StartAsync(Settings(other)); // EZR → manifest d'un autre jeu
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var wrongGame = await Assert.ThrowsAsync<ApiException>(() => owner.GetChangelogAsync("EZR"));
Assert.Equal(503, wrongGame.Status);
Assert.Contains("DEMO", wrongGame.Message);
var missing = await Assert.ThrowsAsync<ApiException>(() => owner.GetChangelogAsync("AUTRE"));
Assert.Equal(503, missing.Status);
Assert.Equal(ApiErrorCodes.NotConfigured, missing.Code);
}
[Fact]
public void Game_manifest_configuration_is_checked_at_startup()
{
var data = Path.Combine(Path.GetTempPath(), "launcher-api-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(data);
var manifest = PublishedManifest();
LauncherApiOptions Options(string id, string file) => new()
{
PublicOrigin = "https://launcher.example",
DataDirectory = data,
Games = new() { [id] = new LauncherApiOptions.GameSettings { ManifestFile = file } }
};
Assert.Throws<InvalidOperationException>(() => Options("../EZR", manifest).Validate("/"));
Assert.Throws<InvalidOperationException>(() => Options("EZR", "exovivalzombie/manifest.json").Validate("/"));
Assert.Throws<InvalidOperationException>(() => Options("EZR", Path.Combine(data, "absent", "manifest.json")).Validate("/"));
Assert.Throws<InvalidOperationException>(() => Options("EZR", Path.Combine(data, "manifest.json")).Validate("/")); // données privées
var ok = Options("EZR", manifest);
ok.Validate("/");
Assert.Equal(manifest, ok.GameManifestFiles["ezr"]);
var disabled = Options("EZR", "");
disabled.Validate("/");
Assert.Empty(disabled.GameManifestFiles);
}
private static string ChangelogStoreHash(string path) => GameLauncher.Server.Storage.ChangelogStore.Sha256(File.ReadAllBytes(path));
}
+1
View File
@@ -2,6 +2,7 @@ using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Services;
+45 -3
View File
@@ -3,6 +3,7 @@ using System.Diagnostics;
using System.IO.Compression;
using System.Text.Json;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Services;
using GameLauncher.Core.Utils;
@@ -15,6 +16,7 @@ namespace ManifestBuilder;
/// Exemple :
/// ManifestBuilder --source "D:\Builds\MonJeu" --output "D:\Publish\MonJeu" --version 1.0.3
/// --exe MonJeu.exe --game-id MonJeu --notes notes.txt
/// --remote-manifest https://launcher.example/monjeu/manifest.json
///
/// Le dossier --output doit être conservé d'une version à l'autre : les fichiers déjà
/// présents dans objects/ ne sont pas recopiés, il suffit d'envoyer les nouveautés.
@@ -64,6 +66,35 @@ public static class Program
}
var gameId = o.GameId ?? previous?.GameId ?? Path.GetFileName(source);
// Historique des notes : celui du manifest EN LIGNE si demandé (corrections faites depuis le menu
// d'administration), sinon celui du manifest local.
GameManifest? published = null;
if (o.RemoteManifest is not null)
{
using var http = new HttpDownloader("ManifestBuilder");
try
{
published = await ChangelogHistory.FetchPublishedAsync(http, o.RemoteManifest);
}
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or InvalidDataException or IOException)
{
throw new InvalidOperationException(
$"Manifest en ligne illisible ({o.RemoteManifest}) : {ex.Message}. Sans lui, les notes corrigées depuis le " +
"menu d'administration seraient perdues : réessayez, ou retirez --remote-manifest pour reprendre l'historique local.", ex);
}
if (published is null)
{
Info("Aucun manifest en ligne (première publication ?) : historique des notes repris du manifest local.");
}
else
{
if (!string.IsNullOrWhiteSpace(published.GameId) && !string.Equals(published.GameId, gameId, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Le manifest en ligne est celui du jeu « {published.GameId} », pas de « {gameId} » : vérifiez --remote-manifest.");
Info($"Historique des notes repris du manifest en ligne : {published.Changelog?.Count ?? 0} version(s) (version en ligne : {published.Version}).");
}
}
var exe = PathHelper.ToManifestPath(o.Executable ?? previous?.Executable ?? "");
if (string.IsNullOrWhiteSpace(exe)) throw new ArgumentException("--exe est obligatoire pour la première publication.");
if (!File.Exists(Path.Combine(source, exe.Replace('/', Path.DirectorySeparatorChar))))
@@ -143,7 +174,6 @@ public static class Program
// 4. Notes de version (l'historique précédent est conservé)
// Format enrichi : titre, sous-titre, introduction, sections, commentaires, sous-points
// (voir ChangelogParser). La liste à plat "changes" reste renseignée pour les anciens launchers.
var changelog = previous?.Changelog?.Where(c => c.Version != o.Version).ToList() ?? new List<ChangelogEntry>();
var entry = new ChangelogEntry { Version = o.Version, Date = DateTimeOffset.Now, Title = o.Title };
if (o.NotesFile is not null)
{
@@ -153,8 +183,7 @@ public static class Program
$"{entry.Sections.Sum(s => s.Items.Count(i => i.IsComment))} commentaire(s).");
}
var parsedItems = entry.Sections.Sum(s => s.Items.Count);
if (!entry.IsEmpty) changelog.Insert(0, entry);
changelog = ChangelogLimits.Normalize(changelog); // mêmes bornes que le launcher
var changelog = ChangelogHistory.Build((published ?? previous)?.Changelog, entry); // mêmes bornes que le launcher
if (changelog.FirstOrDefault(c => c.Version == o.Version) is { } kept && kept.Sections.Sum(s => s.Items.Count) < parsedItems)
Info($"Attention : notes de version trop longues, tronquées à {ChangelogLimits.MaxItemsPerEntry} éléments.");
@@ -201,6 +230,8 @@ public static class Program
Console.WriteLine($" Dossier : {output}");
Console.WriteLine();
Console.WriteLine("Envoyez sur le serveur (dans cet ordre) : objects/ (nouveaux fichiers), l'archive .zip, puis manifest.json.");
if (o.RemoteManifest is not null)
Console.WriteLine("Envoyez-les sans attendre : une note corrigée en ligne d'ici l'envoi serait écrasée (sinon, relancez la publication).");
}
private static Regex GlobToRegex(string glob)
@@ -226,6 +257,9 @@ public static class Program
sections encadrées de ----- (ou "## Section"), "- modification",
" * sous-point", paragraphes = commentaires, "NOUVEAU : …" = étiquette
--title <texte> Titre des notes de version
--remote-manifest <url>
Manifest EN LIGNE du jeu (https) : l'historique des notes en est repris, pour
garder les corrections faites depuis le menu d'administration
--args <texte> Arguments de lancement du jeu
--exclude <motifs> Fichiers à exclure, séparés par ';' (ex. "*.pdb;Logs/**")
--fast Compression rapide (archive plus grosse)
@@ -243,6 +277,7 @@ public static class Program
public string? GameId { get; private set; }
public string? NotesFile { get; private set; }
public string? Title { get; private set; }
public Uri? RemoteManifest { get; private set; }
public string? LaunchArguments { get; private set; }
public List<string> Excludes { get; } = new();
public bool FastCompression { get; private set; }
@@ -264,6 +299,13 @@ public static class Program
case "--game-id": o.GameId = Next(); break;
case "--notes": o.NotesFile = Next(); break;
case "--title": o.Title = Next(); break;
case "--remote-manifest":
var url = Next();
o.RemoteManifest = Uri.TryCreate(url, UriKind.Absolute, out var remote) &&
(remote.Scheme == Uri.UriSchemeHttps || (remote.Scheme == Uri.UriSchemeHttp && remote.IsLoopback))
? remote
: throw new ArgumentException($"--remote-manifest : adresse https:// attendue ({url}).");
break;
case "--args": o.LaunchArguments = Next(); break;
case "--exclude":
o.Excludes.AddRange(Next().Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
+63 -77
View File
@@ -1,102 +1,88 @@
# Version 1.6
# Version 1.8
EXOVIVAL ZOMBIE REBORN - NOTES DE MISE A JOUR
Niveau 1 « L'école » - Mise à jour du 25 octobre 2026
Niveau 1 « L'école » - Mise à jour du 1er novembre 2026
==================================================================
Merci aux bêta-testeurs ! Cette version supprime un blocage possible au gymnase, rend le crochetage plus clair et ajuste l'équilibre des lumières et de l'exploration.
Merci aux bêta-testeurs ! Cette version remplit l'école de zombies qui rôdent, leur donne de l'oreille, rééquilibre les armes et ajoute de petits soins à fabriquer soi-même. Elle corrige aussi des zombies qui restaient bloqués sans attaquer.
------------------------------------------------------------------
PROGRESSION : PROBLÈME BLOQUANT CORRIGÉ
ZOMBIES QUI RÔDENT
------------------------------------------------------------------
- Coffre du gymnase : casser les trois épingles à cheveux ne bloque
plus la partie. La dernière épingle ne casse jamais : vous pouvez
réessayer autant que vous voulez pour récupérer le fusible n°2.
- NOUVEAU : huit zombies rôdent dans l'école dès le début de la
partie.
* autour du car scolaire, sur le terrain de basket ;
* dans la cour de service et sur le toit bas ;
* au gymnase, dans le bureau d'EPS et à l'étage.
- Ils sont plus lents et frappent moins fort que les zombies qui vous
traquent pendant l'histoire : de quoi se défouler un peu. On peut
les distancer en marchant.
- NOUVEAU : trois apparences différentes, dont une nouvelle : le
prisonnier.
- NOUVEAU : alerte de groupe. Quand un zombie vous attaque, ceux du
même endroit qui sont à proximité l'entendent et accourent.
- Les deux zombies de l'histoire (celui qui se réveille au retour du
courant et celui de la bibliothèque) restent les plus dangereux.
Astuce : au gymnase, attaquer un zombie de face attire souvent les autres.
------------------------------------------------------------------
CROCHETAGE
LES ZOMBIES VOUS ENTENDENT
------------------------------------------------------------------
- NOUVEAU : les commandes s'affichent pendant le crochetage.
* souris : placer l'épingle ;
* Espace (maintenir) : tourner la serrure ;
* Q : retour.
- NOUVEAU : le nombre d'épingles restantes s'affiche.
- NOUVEAU : en ramassant les épingles à cheveux, une aide explique
comment crocheter une serrure.
- Le crochetage est plus tolérant : la serrure réagit plus tôt quand
l'épingle approche du bon angle, et une épingle résiste plus
longtemps avant de casser.
Astuce : plus la serrure tourne loin, plus l'épingle est proche du bon angle. Si elle bloque, relâchez et décalez l'épingle : forcer l'use jusqu'à la casser.
- NOUVEAU : marcher ou courir fait du bruit. Un zombie vous entend,
même dans son dos, se retourne et vous attaque.
* courir s'entend de beaucoup plus loin que marcher ;
* un mur ou une porte fermée étouffe le bruit ;
* on n'entend rien d'un étage à l'autre.
- NOUVEAU : accroupi (touche Ctrl), vous êtes silencieux : vous pouvez
approcher un zombie par derrière sans qu'il vous remarque.
- Dès que vous le blessez, le zombie se retourne et fonce sur vous.
- Accroupi ou pas, un zombie qui vous voit en face vous attaque.
------------------------------------------------------------------
OBJETS ET EXPLORATION
ARMES : DÉGÂTS RÉÉQUILIBRÉS
------------------------------------------------------------------
- Fusible n°1 : il n'est plus posé à côté du registre de la salle des
professeurs. Cherchez-le ailleurs dans l'école : le registre
indique où il a été rangé.
- NOUVEAU : un deuxième couteau de poche se trouve dans l'école.
* si vous avez raté celui du car, vous pouvez quand même casser
les caisses et la vitrine de la hache ;
* on ne porte qu'un seul couteau : le second reste en place, avec
un message.
- La bougie de la classe de CM1 est éteinte tant qu'on ne l'a pas
prise en main.
- Chaque arme fait désormais toujours les mêmes dégâts (plus de tirage
au hasard), et viser la tête double les dégâts, à toutes les armes.
* hache : un seul coup à la tête suffit, trois coups au corps ;
* couteau de poche : deux coups à la tête, sept au corps ;
* pistolet : trois balles dans la tête, sept au corps.
Astuce : approchez accroupi dans le dos d'un zombie et frappez à la tête avec la hache.
------------------------------------------------------------------
LUMIÈRES : DURÉE RÉDUITE
PETITS SOINS À LA RESIDENT EVIL
------------------------------------------------------------------
- Lampe torche : une pile dure maintenant 4 minutes (au lieu de 12).
- Lanterne : un bidon de pétrole dure maintenant 2 minutes 30 (au
lieu de 3 minutes).
Gardez un œil sur vos piles !
- NOUVEAU : herbes médicinales, qui soignent un peu.
* les plantes vertes de l'école semblent perdre leurs feuilles :
regardez à leur pied, et au pied des buissons dehors.
- NOUVEAU : huiles médicinales, qui soignent davantage. Quelques
flacons traînent dans l'école.
- NOUVEAU : mélangez une herbe et une huile pour obtenir une potion de
soin, bien plus efficace.
* inventaire (Tab) › herbe › Combiner › huile ;
* c'est le seul mélange possible.
- La trousse de secours reste le meilleur remède.
- Comme la trousse, un soin ne peut pas être utilisé quand vous avez
toute votre vie : il n'est pas gaspillé.
- NOUVEAU : une note de l'infirmière, quelque part dans l'école,
explique tout cela.
- Les herbes et les huiles affichent le petit cercle blanc des objets
à prendre quand on s'en approche.
------------------------------------------------------------------
INVENTAIRE
CORRIGÉ DEPUIS LA VERSION 1.7
------------------------------------------------------------------
- NOUVEAU : inventaire plein. La première fois qu'un objet ne peut pas
être ramassé faute de place, une aide vous l'indique :
* faites de la place (Tab : lâcher ou jeter un objet) ;
* ou réarrangez vos objets ;
* ou déposez-en dans une caisse de rangement de l'école.
- CORRIGÉ : un zombie arrivé au contact du joueur pouvait rester
immobile, les bras croisés, sans plus jamais frapper.
* surtout dans les escaliers ;
* ou quand le joueur était sur un banc, une marche ou des débris ;
* ou quand plusieurs zombies l'entouraient.
Le zombie se tourne maintenant toujours vers le joueur et attaque normalement.
------------------------------------------------------------------
CAISSES ET OBJETS TENUS
------------------------------------------------------------------
- Tourner une caisse (R maintenu + souris) est beaucoup plus réactif :
la rotation suit maintenant le mouvement de la souris.
- La molette rapproche ou éloigne l'objet tenu bien plus vite (avant,
quelques millimètres seulement par cran).
------------------------------------------------------------------
SAUVEGARDES
------------------------------------------------------------------
- Charger une partie : les emplacements sont numérotés dans l'ordre
chronologique. La plus ancienne sauvegarde porte le n°0 et la plus
récente le numéro le plus élevé (avant, c'était l'inverse).
------------------------------------------------------------------
TEXTES ET INTERFACE
------------------------------------------------------------------
- Les messages d'aide de plusieurs lignes ne débordent plus du cadre
noir en haut de l'écran.
- Le registre d'appel est mis à jour (nouvel emplacement du
fusible n°1).
------------------------------------------------------------------
CONNU, EN COURS
------------------------------------------------------------------
- La visée du pistolet fait un simple zoom au lieu de viser par le
viseur. Une amélioration est prévue.
Vos sauvegardes restent compatibles. Une partie commencée avant cette version garde l'ancien emplacement du fusible n°1 (s'il n'a pas encore été ramassé) et la charge des piles déjà en place : pour profiter de tous les changements, commencez une nouvelle partie.
Pour profiter de tous les changements (zombies rôdeurs, herbes, huiles et note de l'infirmière à leur place de départ), commencez une nouvelle partie.
+3
View File
@@ -5,6 +5,8 @@ REM 1. Compilez votre jeu dans D:\Builds\MonJeu
REM 2. Écrivez les nouveautés dans notes.txt (format : voir README, § Format de notes.txt)
REM 3. Lancez ce script, puis envoyez le contenu de D:\Publish\MonJeu sur votre serveur
REM (objects\ et le .zip d'abord, manifest.json en dernier).
REM L'historique des notes est repris du manifest EN LIGNE (--remote-manifest) : les
REM corrections faites depuis le menu d'administration ne sont pas perdues.
REM ---------------------------------------------------------------------------
set VERSION=%1
@@ -20,6 +22,7 @@ dotnet run --project "%~dp0ManifestBuilder\ManifestBuilder.csproj" -c Release --
--exe "Exovival Zombie Reborn.exe" ^
--game-id "EZR" ^
--notes "%~dp0notes.txt" ^
--remote-manifest "https://launcher.lanisys.fr/exovivalzombie/manifest.json" ^
--exclude "*.pdb;*.log" ^
--prune-objects
pause