Compare commits

...
Author SHA1 Message Date
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
nicolas 89775bd73b Documentation : phase 3 (catalogue des jeux) 2026-10-31 10:34:13 +01:00
nicolas 944e47dea7 Déploiement : catalogue sur le VPS (complément systemd, nginx, guide) 2026-10-31 10:33:56 +01:00
nicolas c44c9fe3ef Launcher : catalogue au démarrage, fiche du jeu (description, liens), vues de la page « Jeux » 2026-10-31 10:33:25 +01:00
nicolas 6d44b5680a Administration : module « Catalogue des jeux » et page « Jeux » (ViewModels) 2026-10-31 10:32:30 +01:00
nicolas a6b2f2d6bb Core : catalogue côté joueur (téléchargement borné, cache, images, application) 2026-10-31 10:31:31 +01:00
nicolas e888b4037b Client API : lecture et publication du catalogue 2026-10-31 10:30:31 +01:00
nicolas 417dd78df5 Serveur : module Catalog (GET/PUT v1/catalog, fichier public, révisions, audit) 2026-10-31 10:30:12 +01:00
nicolas c8836a9baf Contrats : format et règles du catalogue des jeux (catalog.json) 2026-10-31 10:27:51 +01:00
68 changed files with 5961 additions and 137 deletions

No files matched your search

+3
View File
@@ -40,8 +40,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD
ProjectSection(SolutionItems) = preProject
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
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{266A4FF5-5752-46BA-803C-09E351720DB2}"
+39 -8
View File
@@ -32,13 +32,13 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| Projet | Rôle |
|---|---|
| `src/GameLauncher` | Application WPF (fenêtres, thème, ViewModels, registre, raccourci) |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation |
| `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 et notes de version (modèle et limites) (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 (vues dans `src\GameLauncher\Views\Admin`) |
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux ») et « Notes de version » (`Changelog/`) (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 |
@@ -49,7 +49,8 @@ src/GameLauncher/
├─ launcher.json Configuration éditeur : un jeu, ou plusieurs (voir launcher.multi-jeux.example.json)
├─ 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)
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
│ dont les pages « Jeux » et « Notes de 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),
@@ -71,12 +72,20 @@ 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.
### Format de `notes.txt`
Texte simple, à écrire comme une note de mise à jour (exemple complet : `tools/notes.txt`) :
@@ -332,7 +341,7 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
| Icône | Remplacez `Assets/launcher.ico` |
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` |
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
@@ -375,7 +384,7 @@ liste des jeux apparaît alors à gauche de la fenêtre (masquée s'il n'y a qu'
|---|---|
| `--game <GameId>` | Ouvre le launcher sur ce jeu, vérifie les mises à jour puis lance le jeu s'il est prêt ; si le launcher est déjà ouvert, lui demande de lancer ce jeu (voir « Raccourcis du Bureau ») |
| `--uninstall [GameId]` | Désinstallation (utilisée par Windows) |
| `--config <fichier>` | Utilise un autre `launcher.json` (tests). En Release, son `apiUrl` est ignorée (seule celle intégrée à l'exe compte) |
| `--config <fichier>` | Utilise un autre `launcher.json` (tests). En Release, ses `apiUrl` et `catalogUrl` sont ignorées (seules celles intégrées à l'exe comptent) |
| `--capture <dossier>` | Enregistre une capture PNG par jeu (+ ses paramètres, + paramètres du launcher), sans rien installer, puis quitte |
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7). Le jeu de `--game` n'est alors lancé que si `--launch` est aussi présent (mise à jour faite au démarrage depuis un raccourci), jamais après une mise à jour demandée depuis les paramètres |
@@ -472,8 +481,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) ;
les pages de gestion arrivent dans les phases suivantes. Rien ne change pour les joueurs.
Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitation, tableau de bord).
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
**Notes de version**. Les autres pages de gestion arrivent dans les phases suivantes.
* **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é,
@@ -496,6 +506,27 @@ les pages de gestion arrivent dans les phases suivantes. Rien ne change pour les
Écran de connexion : « Supprimer cette clé de la puce » (puis révoquer la clé sur le serveur) ;
après trop de codes PIN faux, un décompte indique quand la puce acceptera de nouveau un code.
* **Page « Jeux »** (permission `games.edit`) : nom affiché, accroche, description, liens (Discord,
YouTube…), site, thème intégré, icône et fond, fréquence des recherches de mise à jour, jeu masqué,
nom du launcher, jeu affiché au premier lancement, ordre des jeux. Champ vide = valeur de
`launcher.json`. « Publier » contrôle les valeurs puis écrit `catalog.json` sur le serveur (requête
signée) ; si quelqu'un a publié entre-temps, rien n'est écrasé et la page propose de recharger.
Les jeux eux-mêmes (identifiant, manifest, exécutable, dossiers) restent dans `launcher.json` ;
le nom affiché ne change ni le raccourci, ni le registre, ni les dossiers.
* **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.
* **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 :
PNG/JPEG du même serveur, déposées dans `catalog/images/` (nouveau nom à chaque changement).
Installation sur le VPS : `server/deploy/LISEZMOI.md`, étape 3 bis.
* **Principe** : le launcher étant public, la sécurité est assurée par le **serveur**. Chaque
administrateur possède une **clé privée** (sur son PC, chiffrée) ; le serveur ne connaît que les
clés publiques. Connexion par défi signé, sessions courtes, et **toute modification signée** par
+95 -20
View File
@@ -67,8 +67,8 @@ Conséquences :
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; **affichage** des modules (vues WPF du menu d'administration : `Views/Admin`, `Themes/Admin.xaml`) |
| `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 (inchangée) : manifest, téléchargement, installation… |
| `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.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 ; 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 |
@@ -76,8 +76,9 @@ Conséquences :
| `tests/GameLauncher.Tests` | net8.0 (xUnit) | **Nouveau.** Tests unitaires + tests de bout en bout (serveur démarré en mémoire) |
Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `Server` → `Contracts`
(+ `Core` à partir de la phase 3) ; `Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ;
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`.
(uniquement : le serveur n'utilise pas `Core`) ; `Core` → `Contracts` (règles du catalogue, phase 3) ;
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `GameLauncher` → `Core`, `Modules`,
`Modules.Admin`.
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
## 5. Modules
@@ -306,8 +307,8 @@ sudo -u deploy /opt/launcher-api/GameLauncher.Server add-owner --name "Nicolas"
Les jeux sont **toujours déclarés dans `launcher.json`** : le serveur ne peut ni ajouter ni retirer
un jeu, seulement modifier l'affichage des jeux déclarés. Comme `apiUrl`, la liste des jeux que le
menu d'administration peut modifier sera lue, en Release, dans le `launcher.json` intégré à l'exe. Les champs actuels de `launcher.json` se
répartissent ainsi :
menu d'administration peut modifier est lue, en Release, dans le `launcher.json` intégré à l'exe.
Les champs de `launcher.json` se répartissent ainsi :
| Champ | Reste dans l'exe | Modifiable (serveur) | Raison |
|---|:-:|:-:|---|
@@ -330,20 +331,94 @@ validé et borné comme les notes de version, puis **mis en cache** (`%AppData%\
Sans réseau : dernier cache, sinon valeurs de `launcher.json`. Le serveur écrit `catalog.json`
seulement via l'API (permission `games.edit`, requête signée).
## 10. Notes de version (phase 4)
### 9.1 Mise en œuvre (phase 3 ✅)
* É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`).
**Format** (`Contracts/Catalog`) : `{ "format": "gamelauncher-catalog", "version": 1, "revision", "updatedAt",
"launcher": { "launcherName", "defaultGame", "gameOrder" }, "games": { "<gameId>": { "gameName", "tagline",
"description", "websiteUrl", "links": [{ "kind", "label", "url" }], "theme", "icon", "background",
"updateCheckIntervalMinutes", "hidden" } } }`. Un champ absent = valeur de `launcher.json`.
`CatalogRules` (partagé) borne tout : 48 Ko (sous la limite de 64 Ko des requêtes de l'API), 50 jeux,
noms 80 caractères, accroche 200, description 2000 caractères / 40 lignes, 8 liens, adresses https
(500 caractères, sans identifiants), images `.png`/`.jpg` du **même hôte que le catalogue**, thème =
identifiant (`[a-z0-9-]`, jamais un chemin), intervalle 0 ou 5 à 1440. Le serveur **refuse** une
valeur invalide (400) ; le launcher l'**ignore** (valeur de `launcher.json`).
**Serveur** (module `Catalog`, `Storage/CatalogStore`) :
* `GET v1/catalog` (administrateur connecté) : catalogue publié, publication activée ou non, adresse publique.
* `PUT v1/catalog` (`games.edit` + requête signée) : `{ baseRevision, catalog }`. Révision différente
de celle publiée → 409 `revision_conflict` (rien n'est écrasé) ; contrôle `CatalogRules` ; écriture
de la référence privée (`/var/lib/launcher-api/catalog.json` + `.bak`), puis du **fichier public**
(fichier temporaire du même dossier, droits 644 fixés explicitement malgré `UMask=0077`, renommage
atomique) ; en cas d'échec du fichier public, la référence privée est remise en état (503) ;
historique des 20 dernières versions ; audit `catalog.publish` (jeux modifiés).
* Réglages `LauncherApi:Catalog:PublicFile` (vide = publication désactivée, 503 `not_configured`) et
`PublicUrl` (défaut : `PublicOrigin` + `/catalog/catalog.json`). Au démarrage : chemin absolu `.json`,
dossier existant, dossier public et données privées séparés (aucun dans l'autre, liens résolus).
* VPS : dossier public (ex. `/srv/exovival/www/catalog`) autorisé en écriture par un complément
systemd (`ReadWritePaths`), `catalog.json` servi par nginx sans cache (`server/deploy/LISEZMOI.md`, étape 3 bis).
**Launcher (joueur)** (`Core/Catalog`) :
* `catalogUrl` lue, en Release, **uniquement** dans le `launcher.json` intégré (comme `apiUrl`).
* Démarrage : le cache est appliqué immédiatement ; le catalogue à jour est téléchargé en parallèle
de la vérification de mise à jour du launcher et appliqué s'il arrive en 2,5 s (nouvelles images :
1,5 s de plus) ; sinon il est mis en cache pour le démarrage suivant.
* Téléchargements bornés (48 Ko, images 4 Mo), redirections suivies **uniquement** vers le même
serveur (aucune requête vers un autre hôte), images contrôlées (signature PNG/JPEG), mises en cache
sous l'empreinte de leur adresse (changer d'image = nouveau nom de fichier), fonds réduits à
3840 px au chargement.
* `CatalogMerger` : valeurs de `launcher.json` restaurées avant chaque application ; jeux inconnus
ignorés ; thèmes intégrés uniquement ; images du cache uniquement ; jeu masqué toujours affiché s'il
est installé ou demandé (raccourci) ; au moins un jeu affiché. Le nom du catalogue est un **nom
affiché** : registre, raccourci, dossier d'installation proposé et dossier de données gardent le
`gameName` de `launcher.json`.
* Fiche du jeu : description et boutons de liens (icônes génériques : site, forum, vidéo…).
**Menu d'administration** : module `CatalogAdminModule` (permission `games.edit`), page « Jeux » :
liste « Launcher » + jeux (ordre ↑ ↓), champs vides = valeur de `launcher.json` (rappelée sous le
champ), pastille « modifié », contrôle complet avant l'envoi (mêmes règles que le serveur), saisie
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 ✅)
* É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)
@@ -380,8 +455,8 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
| **1** | Contrats partagés ; Launcher API (modules, authentification par clé, sessions, requêtes signées, invitations/enrôlement/approbation/révocation, audit, limites, CLI) ; client API ; outil `AdminKeyTool` ; tests ; fichiers de déploiement (systemd, nginx) | ✅ |
| **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 » | à faire |
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | à faire |
| **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 | ✅ |
| **5** | Publication d'une version depuis le menu admin (envoi par morceaux, reprise, vérifications) | à faire |
| **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 |
@@ -25,7 +25,9 @@ public static class ApiModules
new Modules.System.SystemModule(),
new Modules.Auth.AuthModule(),
new Modules.Admins.AdminsModule(),
new Modules.Audit.AuditModule()
new Modules.Audit.AuditModule(),
new Modules.Catalog.CatalogModule(),
new Modules.Changelog.ChangelogModule()
};
}
@@ -39,6 +39,14 @@ public sealed class LauncherApiOptions
public LimitSettings Limits { get; set; } = new();
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;
@@ -53,6 +61,32 @@ public sealed class LauncherApiOptions
public int MaxTotal { get; set; } = 500;
}
/// <summary>Catalogue des jeux (informations modifiables), publié en fichier statique servi par nginx.</summary>
public sealed class CatalogSettings
{
/// <summary>
/// Fichier public écrit par le service (chemin absolu, ex. « /srv/exovival/www/catalog/catalog.json »).
/// Vide : publication désactivée (le catalogue reste consultable dans le menu d'administration).
/// Le dossier doit exister, appartenir au compte du service, et être autorisé en écriture par
/// systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
/// </summary>
public string PublicFile { get; set; } = "";
/// <summary>Adresse publique du fichier (information). Vide : « PublicOrigin/catalog/catalog.json ».</summary>
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>
@@ -79,6 +113,22 @@ public sealed class LauncherApiOptions
public IReadOnlyList<IPAddress> TrustedProxyAddresses { get; private set; } = Array.Empty<IPAddress>();
/// <summary>Fichier public du catalogue en chemin absolu (null : publication désactivée), calculé par <see cref="Validate"/>.</summary>
public string? CatalogPublicFileFullPath { get; private set; }
/// <summary>Adresse publique du catalogue, calculée par <see cref="Validate"/>.</summary>
public string CatalogPublicUrl { get; private set; } = "";
/// <summary>
/// Hôte de <see cref="CatalogPublicUrl"/> : seul hôte autorisé pour les images du catalogue (le
/// launcher n'accepte que des images du même hôte que le catalogue).
/// </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)
{
@@ -105,6 +155,9 @@ 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)");
Require(Sessions.MaxPerAdmin is >= 1 and <= 50, "Sessions:MaxPerAdmin (1 à 50)");
@@ -116,6 +169,97 @@ public sealed class LauncherApiOptions
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
}
private void ValidateCatalog()
{
CatalogPublicFileFullPath = null;
if (!string.IsNullOrWhiteSpace(Catalog.PublicFile))
{
var file = Catalog.PublicFile.Trim();
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{Section}:Catalog:PublicFile doit être un chemin absolu vers un fichier .json.");
var full = Path.GetFullPath(file);
var directory = Path.GetDirectoryName(full);
if (directory is null || !Directory.Exists(directory))
throw new InvalidOperationException($"{Section}:Catalog:PublicFile : le dossier « {directory} » n'existe pas.");
EnsureSeparatedFromData(directory, "Catalog:PublicFile");
CatalogPublicFileFullPath = full;
}
if (string.IsNullOrWhiteSpace(Catalog.PublicUrl))
{
CatalogPublicUrl = NormalizedOrigin + "/catalog/catalog.json";
}
else if (Uri.TryCreate(Catalog.PublicUrl.Trim(), UriKind.Absolute, out var url) &&
(url.Scheme == Uri.UriSchemeHttps || (url.Scheme == Uri.UriSchemeHttp && url.IsLoopback)))
{
CatalogPublicUrl = url.AbsoluteUri;
}
else
{
throw new InvalidOperationException($"{Section}:Catalog:PublicUrl doit être une adresse https://.");
}
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)
{
var full = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar);
try
{
var target = new DirectoryInfo(full).ResolveLinkTarget(returnFinalTarget: true);
return target is null ? full : Path.GetFullPath(target.FullName).TrimEnd(Path.DirectorySeparatorChar);
}
catch (IOException)
{
return full;
}
}
private static bool IsSameOrInside(string path, string parent)
{
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
return string.Equals(path, parent, comparison) ||
path.StartsWith(parent + Path.DirectorySeparatorChar, comparison);
}
private static void Require(bool condition, string name)
{
if (!condition) throw new InvalidOperationException($"{Section}:{name} : valeur hors limites.");
@@ -0,0 +1,93 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Catalog;
/// <summary>
/// Catalogue des jeux (informations MODIFIABLES : nom affiché, accroche, présentation, liens, thème
/// intégré, images, intervalle de mise à jour, ordre et visibilité). Les jeux restent déclarés dans le
/// launcher.json intégré à l'exe : le launcher ignore les jeux qu'il ne connaît pas.
/// • GET : tout administrateur connecté (le contenu est public de toute façon) ;
/// • PUT : permission games.edit, requête signée, contrôle complet (<see cref="CatalogRules"/>),
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
/// </summary>
public sealed class CatalogModule : IApiModule
{
public string Name => "Catalog";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<CatalogStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Catalog, Get).RequireAdmin();
api.MapPut(ApiRoutes.Catalog, Publish).RequirePermission(AdminPermissions.GamesEdit).RequireSignedRequest();
}
private static IResult Get(CatalogStore store, IOptions<LauncherApiOptions> options) =>
Results.Json(State(store.Read(), store, options.Value), ApiJson.Options);
private static IResult Publish(CatalogUpdateRequest request, HttpContext context, CatalogStore store, AuditLog audit,
TimeProvider time, IOptions<LauncherApiOptions> options, ILogger<CatalogModule> logger)
{
if (request.Catalog is null) return ApiResults.BadRequest("Catalogue absent.");
if (request.BaseRevision < 0) return ApiResults.BadRequest("Révision invalide.");
var errors = new List<string>();
var catalog = CatalogRules.Normalize(request.Catalog with
{
Format = CatalogDocument.FormatName,
Version = CatalogDocument.CurrentVersion,
Revision = 0
}, options.Value.CatalogImageHost, errors);
if (errors.Count > 0)
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
CatalogDocument published, previous;
try
{
(published, previous) = store.Publish(catalog, request.BaseRevision, time.GetUtcNow());
}
catch (IOException ex)
{
logger.LogError(ex, "Publication du catalogue");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire le catalogue public : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
var actor = context.Admin();
audit.Write("catalog.publish", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", Describe(previous, published));
return Results.Json(State(published, store, options.Value), ApiJson.Options);
}
private static CatalogState State(CatalogDocument catalog, CatalogStore store, LauncherApiOptions options) => new()
{
Catalog = catalog,
PublishingEnabled = store.PublishingEnabled,
PublicUrl = options.CatalogPublicUrl
};
/// <summary>Résumé pour l'audit : jeux et réglages modifiés.</summary>
private static string Describe(CatalogDocument before, CatalogDocument after)
{
var changed = new List<string>();
if (Serialize(before.Launcher) != Serialize(after.Launcher)) changed.Add("launcher");
foreach (var id in before.Games.Keys.Union(after.Games.Keys, StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase))
{
before.Games.TryGetValue(id, out var a);
after.Games.TryGetValue(id, out var b);
if (Serialize(a) != Serialize(b)) changed.Add(id);
}
return changed.Count == 0 ? "aucun changement" : "modifié : " + string.Join(", ", changed.Take(20));
}
private static string Serialize(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
}
@@ -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,173 @@
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Catalogue des jeux (informations modifiables).
/// • Référence : catalog.json dans le dossier des données privées (+ historique des 20 dernières révisions).
/// • Publication : copie dans le fichier PUBLIC servi par nginx (LauncherApi:Catalog:PublicFile),
/// écrite atomiquement (fichier temporaire du même dossier puis renommage), lisible par tous (0644).
/// • Contrôle des modifications concurrentes : une publication précise la révision sur laquelle elle
/// a été préparée ; si quelqu'un a publié entre-temps, elle est refusée (409).
/// </summary>
public sealed class CatalogStore
{
public const string FileName = "catalog.json";
private const string HistoryFolder = "catalog-history";
private const int HistoryLength = 20;
private static readonly JsonSerializerOptions Json = CatalogRules.PublishedJson;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private const UnixFileMode PublicFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
private readonly string _path;
private readonly string _lockPath;
private readonly string _historyDirectory;
private readonly string? _publicFile;
private readonly object _writeLock = new();
public CatalogStore(IOptions<LauncherApiOptions> options)
: this(options.Value.DataDirectoryFullPath, options.Value.CatalogPublicFileFullPath) { }
public CatalogStore(string dataDirectory, string? publicFile)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
_publicFile = publicFile;
}
public bool PublishingEnabled => _publicFile is not null;
/// <summary>Catalogue actuel (vide, révision 0, s'il n'a jamais été publié).</summary>
public CatalogDocument Read()
{
if (!File.Exists(_path)) return new CatalogDocument();
var text = File.ReadAllText(_path);
var document = JsonSerializer.Deserialize<CatalogDocument>(text, Json)
?? throw new InvalidDataException($"{FileName} est vide.");
var errors = new List<string>();
return CatalogRules.Normalize(document, imageHost: null, errors) with { Revision = document.Revision };
}
/// <summary>
/// Publie un catalogue déjà contrôlé (<see cref="CatalogRules.Normalize"/>) : révision suivante,
/// fichier public, puis référence privée et historique.
/// </summary>
/// <returns>Le catalogue publié et le précédent.</returns>
public (CatalogDocument Published, CatalogDocument Previous) Publish(CatalogDocument catalog, long baseRevision, DateTimeOffset now)
{
if (_publicFile is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication du catalogue non configurée sur le serveur (LauncherApi:Catalog:PublicFile).");
lock (_writeLock)
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
{
var current = Read();
if (current.Revision != baseRevision)
throw ApiRejection.Conflict(
$"Le catalogue a été publié entre-temps (révision {current.Revision}) : rechargez-le, puis refaites vos modifications.",
ApiErrorCodes.RevisionConflict);
var published = catalog with
{
Format = CatalogDocument.FormatName,
Version = CatalogDocument.CurrentVersion,
Revision = current.Revision + 1,
UpdatedAt = now
};
var json = JsonSerializer.Serialize(published, Json);
if (Encoding.UTF8.GetByteCount(json) > CatalogRules.MaxFileBytes)
throw ApiRejection.BadRequest($"Catalogue trop volumineux (au plus {CatalogRules.MaxFileBytes / 1024} Ko).");
// 1. Référence privée (+ .bak) : c'est elle qui porte la révision.
var previousText = File.Exists(_path) ? File.ReadAllText(_path) : null;
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
// 2. Fichier public. En cas d'échec, la référence privée est remise dans son état précédent :
// les joueurs et le serveur restent sur la même révision (rien n'est publié à moitié).
try
{
WritePublic(json);
}
catch (IOException)
{
RestorePrivate(previousText);
throw;
}
// 3. Historique (aide uniquement).
WriteHistory(published.Revision, json);
return (published, current);
}
}
private void WritePublic(string json)
{
var directory = Path.GetDirectoryName(_publicFile!)!;
var temp = Path.Combine(directory, $".{Path.GetFileName(_publicFile)}.{Guid.NewGuid():N}.tmp");
try
{
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = PublicFileMode;
using (var stream = new FileStream(temp, options))
{
stream.Write(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json));
stream.Flush(flushToDisk: true);
}
// Le masque du service (UMask=0077) s'applique à la création : droits fixés explicitement
// pour que nginx puisse lire le fichier (lecture seule pour tous, écriture par le service seul).
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, PublicFileMode);
File.Move(temp, _publicFile!, overwrite: true); // renommage atomique dans le même dossier
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
try
{
File.Delete(temp);
}
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
{
// fichier temporaire laissé : sans conséquence (nom unique)
}
throw new IOException($"Écriture du catalogue public impossible ({_publicFile}) : {ex.Message}", ex);
}
}
private void RestorePrivate(string? previousText)
{
try
{
if (previousText is null) File.Delete(_path);
else SecureFiles.WriteAllTextAtomic(_path, previousText, keepBackup: false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Très improbable (le dossier privé vient d'être écrit) : la révision suivante sera alors
// refusée (409) jusqu'au rechargement de la page, sans rien écraser.
}
}
private void WriteHistory(long revision, string json)
{
try
{
SecureFiles.EnsureDirectory(_historyDirectory);
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"catalog-{revision:D6}.json"), json, keepBackup: false);
foreach (var old in Directory.GetFiles(_historyDirectory, "catalog-*.json").OrderByDescending(f => f, StringComparer.Ordinal).Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// L'historique est une aide : son échec n'annule pas la publication.
}
}
}
@@ -0,0 +1,310 @@
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.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 = 64L * 1024 * 1024;
private const string HistoryFolder = "manifest-history";
private const int HistoryLength = 20;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(10);
/// <summary>Même présentation que ManifestBuilder (indenté, camelCase, accents non échappés).</summary>
private static readonly JsonSerializerOptions ManifestJson = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
MaxDepth = 64
};
private static readonly JsonDocumentOptions ReadOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
MaxDepth = 64
};
/// <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 (SecureFiles.AcquireLock(Path.Combine(_dataDirectory, $"manifest-{key.ToLowerInvariant()}.lock"), LockTimeout))
{
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(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, ManifestJson)!.AsObject();
foreach (var name in EditableFields)
{
foreach (var existing in replacement.Select(p => p.Key).Where(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase)).ToList())
replacement.Remove(existing);
if (edited[name] is { } value) replacement[name] = value.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 json = root.ToJsonString(ManifestJson);
var newBytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(json);
if (newBytes.LongLength > MaxManifestBytes) throw ApiRejection.BadRequest("Manifest trop volumineux.");
Backup(key, bytes);
WriteManifest(path, newBytes, baseSha256);
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 new ApiRejection(503, ApiErrorCodes.NotConfigured,
$"Notes de version non modifiables pour « {gameId} » : manifest non configuré sur le serveur (LauncherApi:Games:{gameId}:ManifestFile).");
}
private static byte[] ReadManifest(string path)
{
var info = new FileInfo(path);
if (!info.Exists) throw ApiRejection.NotFound("Aucun manifest publié pour ce jeu (publiez d'abord une version).");
if (info.Length > MaxManifestBytes) throw new InvalidDataException("Manifest trop volumineux.");
return File.ReadAllBytes(path);
}
private static JsonObject Parse(byte[] bytes, string gameId)
{
JsonObject root;
try
{
root = JsonNode.Parse(StripBom(bytes), documentOptions: ReadOptions) as JsonObject
?? throw new InvalidDataException("le manifest n'est pas un objet JSON");
}
catch (Exception ex) when (ex is JsonException or ArgumentException)
{
throw new InvalidDataException($"Manifest illisible : {ex.Message}", ex);
}
// Garde-fou de configuration : le fichier configuré doit bien être celui de ce jeu.
var manifestGameId = 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 = 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>
private static List<ChangelogEntry?> Entries(JsonArray? changelog)
{
try
{
return changelog?.Deserialize<List<ChangelogEntry?>>(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>
private static JsonArray? ChangelogArray(JsonObject root)
{
foreach (var (name, value) in root)
if (string.Equals(name, "changelog", StringComparison.OrdinalIgnoreCase))
return value as JsonArray;
return null;
}
private static string? StringValue(JsonObject node, string name)
{
foreach (var (key, value) in node)
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
return value is JsonValue v && v.TryGetValue<string>(out var text) ? text : null;
return null;
}
private static ReadOnlySpan<byte> StripBom(byte[] bytes) =>
bytes.AsSpan().StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ? bytes.AsSpan(3) : bytes;
public static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
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);
// ------------------------------------------------------------------ Écriture
/// <summary>Copie de l'ancien manifest dans les données privées (aide à la récupération).</summary>
private void Backup(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 la modification.
}
}
/// <summary>
/// Remplace le manifest publié : fichier temporaire caché (refusé par nginx) dans le même dossier,
/// droits du manifest existant, empreinte revérifiée, puis renommage atomique.
/// </summary>
private static void WriteManifest(string path, byte[] bytes, string baseSha256)
{
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.");
if (!OperatingSystem.IsWindows())
{
// Le fichier remplaçant appartient au compte du service, avec le groupe du dossier SEULEMENT si
// celui-ci a le bit setgid : sinon nginx (groupe www-data) pourrait perdre l'accès au manifest.
var mode = File.GetUnixFileMode(path);
if (!mode.HasFlag(UnixFileMode.OtherRead) && mode.HasFlag(UnixFileMode.GroupRead) &&
!File.GetUnixFileMode(directory).HasFlag(UnixFileMode.SetGroup))
throw new ManifestWriteRefusedException(
"le dossier du jeu n'a pas le bit setgid (chmod g+s) : le manifest remplacé ne serait plus lisible par nginx.");
}
var temp = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp");
try
{
using (var stream = new FileStream(temp, new FileStreamOptions
{
Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None
}))
{
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(temp, File.GetUnixFileMode(path));
// Dernier contrôle : un envoi (WinSCP…) pendant la préparation n'est pas écrasé.
if (!string.Equals(Sha256(File.ReadAllBytes(path)), baseSha256, StringComparison.OrdinalIgnoreCase))
throw Conflict();
File.Move(temp, path, overwrite: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ApiRejection)
{
try
{
File.Delete(temp);
}
catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException)
{
// fichier temporaire laissé : sans conséquence (nom unique, caché)
}
if (ex is ApiRejection) throw;
throw new IOException($"Écriture du manifest impossible ({path}) : {ex.Message}", ex);
}
}
}
/// <summary>Remplacement du manifest refusé par précaution (configuration du dossier à corriger) ; message sans chemin.</summary>
public sealed class ManifestWriteRefusedException : IOException
{
public ManifestWriteRefusedException(string message) : base(message) { }
}
@@ -24,6 +24,17 @@
},
"ChallengeLifetimeSeconds": 60,
"InvitationLifetimeHours": 24,
// Catalogue des jeux (phase 3) : fichier public écrit par le service, servi par nginx.
// Vide : publication désactivée. À renseigner dans appsettings.Production.json sur le VPS
// (voir server/deploy/LISEZMOI.md), ex. « /srv/exovival/www/catalog/catalog.json ».
"Catalog": {
"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,
"AuthPerMinute": 20,
+118 -1
View File
@@ -65,6 +65,114 @@ Le script installe les binaires dans `/opt/launcher-api` (propriétaire root), l
différente de l'adresse utilisée, corrige `PublicOrigin` (étape 2) : sinon toutes les
signatures seront refusées.
## 3 bis. Catalogue des jeux (VPS)
La page **Jeux** du menu d'administration publie `catalog.json` (nom affiché, accroche, description,
liens, thème, images, ordre des jeux), que les launchers des joueurs lisent au démarrage à l'adresse
`catalogUrl` de `launcher.json`. Le serveur l'écrit dans **un seul** dossier, servi par nginx, séparé
de ses données privées. Exemple ci-dessous avec le site de « Serveur de téléchargement Exovival »
(`/srv/exovival/www`) : adapte le chemin à la racine réelle de ton site.
1. Crée le dossier public (propriétaire `deploy`, groupe `www-data` pour nginx) :
```
sudo install -d -m 2750 -o deploy -g www-data /srv/exovival/www/catalog /srv/exovival/www/catalog/images
```
2. Indique le fichier au serveur dans `/opt/launcher-api/appsettings.Production.json` (à fusionner
avec tes autres réglages) :
```
{ "LauncherApi": { "Catalog": { "PublicFile": "/srv/exovival/www/catalog/catalog.json" } } }
```
L'adresse publique est déduite de `PublicOrigin` (`https://launcher.lanisys.fr/catalog/catalog.json`) ;
sinon, ajoute `"PublicUrl"`. Elle doit être **identique** à `catalogUrl` dans `launcher.json`.
Le serveur refuse de démarrer si le dossier n'existe pas, ou si le dossier public et
`/var/lib/launcher-api` sont l'un dans l'autre.
3. Autorise le service à écrire dans ce dossier, et seulement lui (le reste du système reste en
lecture seule) :
```
sudo mkdir -p /etc/systemd/system/launcher-api.service.d
sudo nano /etc/systemd/system/launcher-api.service.d/catalog.conf
```
Colle le contenu de `server/deploy/launcher-api-catalog.conf` (ligne `ReadWritePaths=` adaptée),
puis :
```
sudo systemctl daemon-reload && sudo systemctl restart launcher-api
```
Ce fichier est conservé lors des mises à jour du serveur.
4. nginx : colle le bloc `location = /catalog/catalog.json { … }` de `server/deploy/nginx-catalog.conf`
dans le bloc HTTPS du site, avant `location / {`, puis `sudo nginx -t && sudo systemctl reload nginx`.
5. Publie depuis la page **Jeux**, puis vérifie depuis ton PC :
```
PS> curl.exe -sI https://launcher.lanisys.fr/catalog/catalog.json
```
Attendu : `200 OK` et `Cache-Control: no-cache, no-store, must-revalidate`. Le fichier est en
lecture seule pour tous (`-rw-r--r--`) : seul le service le modifie.
**Images** (icône, fond) : envoie-les en SFTP (compte `deploy`) dans `www/catalog/images/`, en PNG
ou JPEG de 4 Mo au plus, puis saisis leur adresse dans la page Jeux, par ex.
`https://launcher.lanisys.fr/catalog/images/fond-halloween.jpg`. Les launchers gardent chaque image
en cache : pour changer une image, envoie-la sous un **nouveau nom**. Les images d'un autre
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.
## 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 :
@@ -132,7 +240,8 @@ 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 ; les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
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.
## 9. Récupération (VPS)
@@ -181,6 +290,14 @@ recréer les administrateurs.
├─ admins.json (+ .bak) administrateurs, clés publiques, invitations (empreintes)
├─ server-id identifiant aléatoire du serveur
├─ 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 par la page Notes de version (20 derniers)
└─ 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é)
```
+14
View File
@@ -0,0 +1,14 @@
# =============================================================================
# Launcher API : autorisation d'écrire le catalogue des jeux (LISEZMOI.md, étape 3 bis).
# /etc/systemd/system/launcher-api.service.d/catalog.conf
#
# Le service est enfermé en lecture seule (ProtectSystem=strict) : ce complément l'autorise à
# écrire dans UN SEUL dossier, celui du fichier public du catalogue (LauncherApi:Catalog:PublicFile).
# Fichier séparé : il est conservé lors des mises à jour du serveur (installer-api.sh remplace
# launcher-api.service, pas ce dossier).
#
# Adapter le chemin au dossier réellement servi par nginx (exemple de la documentation ci-dessous),
# puis : sudo systemctl daemon-reload && sudo systemctl restart launcher-api
# =============================================================================
[Service]
ReadWritePaths=/srv/exovival/www/catalog
+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
+4
View File
@@ -6,6 +6,10 @@
# terminal), enfermé par systemd : système en lecture seule, pas d'accès à /home, aucun privilège.
# Ses binaires (/opt/launcher-api) appartiennent à root : il ne peut pas se modifier lui-même.
# 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
+20
View File
@@ -0,0 +1,20 @@
# =============================================================================
# Catalogue des jeux (écrit par la Launcher API) — un ajout (voir LISEZMOI.md, étape 3 bis) :
#
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
# AVANT « location / { … } » : (le bloc ci-dessous)
#
# Puis : sudo nginx -t && sudo systemctl reload nginx
#
# Les images du catalogue (catalog/images/*.png|jpg) sont servies par « location / » ; les
# fichiers temporaires de la Launcher API (.catalog.json.….tmp) par la règle des fichiers cachés
# (refusés).
# =============================================================================
# Catalogue : toujours relu sur le serveur (les launchers le lisent au démarrage).
location = /catalog/catalog.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
@@ -1,5 +1,7 @@
using System.Security.Cryptography;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
@@ -107,6 +109,37 @@ public sealed class AdminSession : IAsyncDisposable
return GetAsync<AuditPage>(route, ct);
}
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
public Task<CatalogState> GetCatalogAsync(CancellationToken ct = default) =>
GetAsync<CatalogState>(ApiRoutes.Catalog, ct);
/// <summary>Publie le catalogue (409 « revision_conflict » si quelqu'un a publié depuis <paramref name="baseRevision"/>).</summary>
public Task<CatalogState> PublishCatalogAsync(CatalogDocument catalog, long baseRevision, CancellationToken ct = default) =>
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);
}
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) =>
+12
View File
@@ -36,4 +36,16 @@ public static class ApiRoutes
// ------------------------------------------------------------------ Audit
public const string Audit = "v1/audit";
// ------------------------------------------------------------------ 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}";
}
@@ -0,0 +1,126 @@
namespace GameLauncher.Contracts.Catalog;
/// <summary>
/// Informations MODIFIABLES des jeux (catalog.json, publié par le serveur et lu par les launchers).
/// Les jeux restent déclarés dans launcher.json (intégré à l'exe) : une entrée pour un jeu non déclaré
/// est ignorée par le launcher. Toute valeur absente (null) = valeur de launcher.json.
/// Ce fichier est PUBLIC : il ne contient que de l'affichage (aucun nom d'administrateur).
/// </summary>
public sealed record CatalogDocument
{
public const string FormatName = "gamelauncher-catalog";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Numéro de version du contenu, augmenté à chaque publication (contrôle des modifications concurrentes).</summary>
public long Revision { get; init; }
public DateTimeOffset? UpdatedAt { get; init; }
public CatalogLauncher? Launcher { get; init; }
/// <summary>Par identifiant de jeu (GameId de launcher.json).</summary>
public Dictionary<string, CatalogGame> Games { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}
/// <summary>Réglages d'affichage du launcher (liste des jeux).</summary>
public sealed record CatalogLauncher
{
public string? LauncherName { get; init; }
/// <summary>Jeu affiché au premier lancement.</summary>
public string? DefaultGame { get; init; }
/// <summary>Ordre des jeux dans la liste (les jeux non cités suivent, dans l'ordre de launcher.json).</summary>
public List<string>? GameOrder { get; init; }
}
/// <summary>Informations modifiables d'un jeu (null : valeur de launcher.json).</summary>
public sealed record CatalogGame
{
/// <summary>Nom AFFICHÉ (le nom de launcher.json reste celui du raccourci, du registre et des dossiers).</summary>
public string? GameName { get; init; }
public string? Tagline { get; init; }
/// <summary>Texte de présentation (plusieurs lignes, texte brut).</summary>
public string? Description { get; init; }
public string? WebsiteUrl { get; init; }
public List<CatalogLink>? Links { get; init; }
/// <summary>Thème INTÉGRÉ au launcher (« default », « exovival »…), jamais un fichier.</summary>
public string? Theme { get; init; }
/// <summary>Image https (PNG ou JPEG) du même serveur que le catalogue.</summary>
public string? Icon { get; init; }
/// <summary>Image de fond https (PNG ou JPEG) du même serveur que le catalogue.</summary>
public string? Background { get; init; }
/// <summary>0 (désactivé) ou 5 à 1440 minutes.</summary>
public int? UpdateCheckIntervalMinutes { get; init; }
/// <summary>Masqué de la liste (un jeu installé sur le PC reste toujours affiché).</summary>
public bool? Hidden { get; init; }
}
/// <summary>Lien affiché sous la présentation du jeu (Discord, YouTube…).</summary>
public sealed record CatalogLink
{
/// <summary>Voir <see cref="CatalogLinkKinds"/> (choisit l'icône intégrée).</summary>
public string Kind { get; init; } = CatalogLinkKinds.Website;
public string? Label { get; init; }
public string Url { get; init; } = "";
}
public static class CatalogLinkKinds
{
public const string Website = "website";
public const string Discord = "discord";
public const string YouTube = "youtube";
public const string X = "x";
public const string Twitch = "twitch";
public const string Steam = "steam";
public const string Other = "other";
public static readonly IReadOnlyList<string> All = new[] { Website, Discord, YouTube, X, Twitch, Steam, Other };
/// <summary>Libellé par défaut d'un lien.</summary>
public static string DefaultLabel(string kind) => kind switch
{
Website => "Site web",
Discord => "Discord",
YouTube => "YouTube",
X => "X (Twitter)",
Twitch => "Twitch",
Steam => "Steam",
_ => "Lien"
};
}
/// <summary>Publication du catalogue depuis le menu d'administration (requête signée, permission games.edit).</summary>
public sealed record CatalogUpdateRequest
{
/// <summary>Révision sur laquelle les modifications ont été faites (refus si quelqu'un a publié entre-temps).</summary>
public long BaseRevision { get; init; }
public CatalogDocument Catalog { get; init; } = new();
}
/// <summary>Catalogue tel que connu du serveur, pour le menu d'administration.</summary>
public sealed record CatalogState
{
public CatalogDocument Catalog { get; init; } = new();
/// <summary>Faux : la publication n'est pas configurée sur le serveur (LauncherApi:Catalog:PublicFile).</summary>
public bool PublishingEnabled { get; init; }
/// <summary>Adresse publique du catalogue (pour information).</summary>
public string? PublicUrl { get; init; }
}
@@ -0,0 +1,274 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Catalog;
/// <summary>
/// Contrôle et nettoyage du catalogue, communs au serveur (refus de toute valeur invalide) et au
/// launcher des joueurs (valeur invalide ignorée : la valeur de launcher.json s'applique).
/// Tout est borné : textes, nombre de jeux et de liens, adresses (https uniquement), images (même
/// serveur que le catalogue, PNG ou JPEG), thème (identifiant d'un thème intégré, jamais un fichier).
/// </summary>
public static partial class CatalogRules
{
/// <summary>
/// Taille maximale de catalog.json. Inférieure à la taille maximale d'une requête de l'API (64 Ko :
/// Limits:MaxRequestBodyBytes et client_max_body_size de nginx), qui n'est donc pas à augmenter.
/// </summary>
public const int MaxFileBytes = 48 * 1024;
/// <summary>Format du fichier publié (JSON indenté, écrit par le serveur).</summary>
public static readonly JsonSerializerOptions PublishedJson = new(ApiJson.Options) { WriteIndented = true };
/// <summary>Taille (octets) du fichier publié pour ce catalogue.</summary>
public static int PublishedSize(CatalogDocument catalog) =>
Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(catalog, PublishedJson));
public const int MaxGames = 50;
public const int MaxGameNameLength = 80;
public const int MaxLauncherNameLength = 80;
public const int MaxTaglineLength = 200;
public const int MaxDescriptionLength = 2000;
public const int MaxDescriptionLines = 40;
public const int MaxLinks = 8;
public const int MaxLinkLabelLength = 40;
public const int MaxUrlLength = 500;
public const int MinUpdateInterval = 5;
public const int MaxUpdateInterval = 1440;
[GeneratedRegex(@"\A[A-Za-z0-9_.\-]{1,64}\z")]
private static partial Regex GameIdRegex();
[GeneratedRegex(@"\A[a-z0-9][a-z0-9\-]{0,31}\z")]
private static partial Regex ThemeRegex();
public static bool IsValidGameId(string? id) => id is not null && GameIdRegex().IsMatch(id);
public static bool IsValidTheme(string? theme) => theme is not null && ThemeRegex().IsMatch(theme);
/// <summary>
/// Nettoie le catalogue. Chaque valeur invalide est retirée (null) et décrite dans <paramref name="errors"/> :
/// le serveur refuse la publication s'il y en a, le launcher ignore simplement ces valeurs.
/// </summary>
/// <param name="imageHost">Hôte autorisé pour les images (celui du catalogue) ; null : https suffit.</param>
public static CatalogDocument Normalize(CatalogDocument input, string? imageHost, List<string> errors)
{
if (input.Format != CatalogDocument.FormatName) errors.Add("Format de catalogue inconnu.");
if (input.Version != CatalogDocument.CurrentVersion) errors.Add($"Version de catalogue non prise en charge ({input.Version}).");
if (input.Revision < 0) errors.Add("Révision invalide.");
var games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
foreach (var (id, game) in input.Games ?? new Dictionary<string, CatalogGame>())
{
if (!IsValidGameId(id))
{
errors.Add($"Identifiant de jeu invalide : « {Truncate(id)} ».");
continue;
}
if (games.ContainsKey(id))
{
errors.Add($"Jeu « {id} » présent plusieurs fois.");
continue;
}
if (games.Count >= MaxGames)
{
errors.Add($"Trop de jeux (au plus {MaxGames}).");
break;
}
if (game is null) continue;
var cleaned = NormalizeGame(id, game, imageHost, errors);
if (!IsEmpty(cleaned)) games[id] = cleaned;
}
return new CatalogDocument
{
Revision = Math.Max(0, input.Revision),
UpdatedAt = input.UpdatedAt,
Launcher = NormalizeLauncher(input.Launcher, errors),
Games = games
};
}
private static CatalogLauncher? NormalizeLauncher(CatalogLauncher? launcher, List<string> errors)
{
if (launcher is null) return null;
var name = Line(launcher.LauncherName, MaxLauncherNameLength, "Nom du launcher", errors);
string? defaultGame = null;
if (!string.IsNullOrWhiteSpace(launcher.DefaultGame))
{
if (IsValidGameId(launcher.DefaultGame.Trim())) defaultGame = launcher.DefaultGame.Trim();
else errors.Add("Jeu par défaut : identifiant invalide.");
}
List<string>? order = null;
if (launcher.GameOrder is { Count: > 0 })
{
order = new List<string>();
foreach (var id in launcher.GameOrder)
{
if (!IsValidGameId(id))
{
errors.Add($"Ordre des jeux : identifiant invalide « {Truncate(id)} ».");
continue;
}
if (order.Count >= MaxGames)
{
errors.Add($"Ordre des jeux : au plus {MaxGames} jeux.");
break;
}
if (!order.Contains(id, StringComparer.OrdinalIgnoreCase)) order.Add(id);
}
if (order.Count == 0) order = null;
}
return name is null && defaultGame is null && order is null
? null
: new CatalogLauncher { LauncherName = name, DefaultGame = defaultGame, GameOrder = order };
}
private static CatalogGame NormalizeGame(string id, CatalogGame game, string? imageHost, List<string> errors)
{
var prefix = $"Jeu « {id} »";
int? interval = null;
if (game.UpdateCheckIntervalMinutes is { } minutes)
{
if (minutes == 0 || minutes is >= MinUpdateInterval and <= MaxUpdateInterval) interval = minutes;
else errors.Add($"{prefix} : intervalle de recherche des mises à jour invalide (0, ou {MinUpdateInterval} à {MaxUpdateInterval} minutes).");
}
string? theme = null;
if (!string.IsNullOrWhiteSpace(game.Theme))
{
var value = game.Theme.Trim();
if (IsValidTheme(value)) theme = value;
else errors.Add($"{prefix} : thème invalide (identifiant d'un thème intégré au launcher).");
}
return new CatalogGame
{
GameName = Line(game.GameName, MaxGameNameLength, $"{prefix} : nom", errors),
Tagline = Line(game.Tagline, MaxTaglineLength, $"{prefix} : accroche", errors),
Description = Text(game.Description, $"{prefix} : présentation", errors),
WebsiteUrl = Url(game.WebsiteUrl, $"{prefix} : site web", errors),
Links = Links(game.Links, prefix, errors),
Theme = theme,
Icon = Image(game.Icon, imageHost, $"{prefix} : icône", errors),
Background = Image(game.Background, imageHost, $"{prefix} : image de fond", errors),
UpdateCheckIntervalMinutes = interval,
Hidden = game.Hidden == true ? true : null
};
}
private static List<CatalogLink>? Links(List<CatalogLink>? links, string prefix, List<string> errors)
{
if (links is not { Count: > 0 }) return null;
var result = new List<CatalogLink>();
foreach (var link in links)
{
if (link is null) continue;
if (result.Count >= MaxLinks)
{
errors.Add($"{prefix} : au plus {MaxLinks} liens.");
break;
}
var kind = CatalogLinkKinds.All.Contains(link.Kind) ? link.Kind : null;
if (kind is null)
{
errors.Add($"{prefix} : type de lien inconnu « {Truncate(link.Kind)} ».");
continue;
}
var url = Url(link.Url, $"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)}", errors);
if (url is null)
{
if (string.IsNullOrWhiteSpace(link.Url)) errors.Add($"{prefix} : lien {CatalogLinkKinds.DefaultLabel(kind)} sans adresse.");
continue;
}
var label = Line(link.Label, MaxLinkLabelLength, $"{prefix} : libellé de lien", errors);
result.Add(new CatalogLink { Kind = kind, Label = label, Url = url });
}
return result.Count == 0 ? null : result;
}
// ------------------------------------------------------------------ Valeurs
private static string? Line(string? value, int maxLength, string field, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var cleaned = ApiText.CleanLine(value, maxLength);
if (cleaned is null) errors.Add($"{field} : {maxLength} caractères au plus, sans caractère spécial invisible.");
return cleaned;
}
/// <summary>Texte de plusieurs lignes : fins de ligne normalisées, sans caractère de contrôle ou invisible.</summary>
private static string? Text(string? value, string field, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var lines = value.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var sb = new StringBuilder(value.Length);
for (var i = 0; i < lines.Length; i++)
{
if (i > 0) sb.Append('\n');
var line = lines[i].TrimEnd();
if (line.Length == 0) continue;
// Indentation conservée (espaces de tête), le reste nettoyé comme une ligne simple.
var indent = line.Length - line.TrimStart(' ').Length;
var cleaned = ApiText.CleanLine(line, MaxDescriptionLength);
if (cleaned is null)
{
errors.Add($"{field} : caractère spécial invisible ou de contrôle interdit.");
return null;
}
sb.Append(' ', Math.Min(indent, 8)).Append(cleaned);
}
var text = sb.ToString().Trim('\n');
if (text.Length > MaxDescriptionLength || text.Count(c => c == '\n') + 1 > MaxDescriptionLines)
{
errors.Add($"{field} : {MaxDescriptionLength} caractères et {MaxDescriptionLines} lignes au plus.");
return null;
}
return text.Length == 0 ? null : text;
}
/// <summary>Adresse https absolue, sans identifiants, bornée.</summary>
public static string? Url(string? value, string field, List<string> errors)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var text = value.Trim();
if (text.Length > MaxUrlLength || !Uri.TryCreate(text, UriKind.Absolute, out var uri) ||
uri.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(uri.UserInfo) || string.IsNullOrEmpty(uri.Host) ||
text.Any(char.IsWhiteSpace) || text.Any(char.IsControl))
{
errors.Add($"{field} : adresse https:// complète attendue ({MaxUrlLength} caractères au plus).");
return null;
}
return uri.AbsoluteUri;
}
/// <summary>Image https PNG ou JPEG, sur l'hôte du catalogue.</summary>
private static string? Image(string? value, string? imageHost, string field, List<string> errors)
{
var url = Url(value, field, errors);
if (url is null) return null;
var uri = new Uri(url);
var path = uri.AbsolutePath;
if (!(path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)) || !string.IsNullOrEmpty(uri.Query))
{
errors.Add($"{field} : image .png ou .jpg attendue (sans paramètres).");
return null;
}
if (imageHost is not null && !string.Equals(uri.Host, imageHost, StringComparison.OrdinalIgnoreCase))
{
errors.Add($"{field} : l'image doit être sur le serveur du catalogue ({imageHost}).");
return null;
}
return url;
}
private static bool IsEmpty(CatalogGame game) =>
game is { GameName: null, Tagline: null, Description: null, WebsiteUrl: null, Links: null, Theme: null, Icon: null,
Background: null, UpdateCheckIntervalMinutes: null, Hidden: null };
private static string Truncate(string? value) =>
value is null ? "" : value.Length <= 40 ? value : value[..40] + "…";
}
@@ -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; }
}
@@ -53,4 +53,10 @@ public static class ApiErrorCodes
public const string SelfAction = "self_action";
public const string Busy = "busy";
public const string HttpsRequired = "https_required";
/// <summary>Quelqu'un a modifié la même donnée entre-temps (révision dépassée) : recharger avant de recommencer.</summary>
public const string RevisionConflict = "revision_conflict";
/// <summary>Fonction non configurée sur le serveur.</summary>
public const string NotConfigured = "not_configured";
}
@@ -0,0 +1,87 @@
using System.Runtime.CompilerServices;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Catalog;
/// <summary>
/// Applique le catalogue aux jeux déclarés dans launcher.json — affichage uniquement :
/// • jeux inconnus de launcher.json : ignorés (le serveur ne peut ni ajouter ni retirer un jeu) ;
/// • nom AFFICHÉ seulement (raccourci, registre et dossier des données gardent le nom de launcher.json) ;
/// • thème : seulement un thème intégré au launcher ;
/// • images : seulement des fichiers du cache local (téléchargés et contrôlés par <see cref="CatalogService"/>) ;
/// • jeu masqué : toujours affiché s'il est installé sur le PC ou demandé (raccourci, désinstallation),
/// et au moins un jeu reste affiché.
/// Peut être appliqué plusieurs fois (cache au démarrage, puis version à jour) : les valeurs de
/// launcher.json sont rétablies avant chaque application.
/// </summary>
public static class CatalogMerger
{
/// <summary>Valeurs de launcher.json avant toute application du catalogue.</summary>
private sealed record Baseline(string Tagline, string? WebsiteUrl, string Theme, string? Icon, string? Background, int UpdateCheckIntervalMinutes);
private static readonly ConditionalWeakTable<LauncherConfig, Baseline> Baselines = new();
/// <param name="builtInThemes">Thèmes intégrés au launcher (les autres valeurs sont ignorées).</param>
/// <param name="localImage">Fichier local d'une image du catalogue (null : pas encore en cache → image par défaut).</param>
/// <param name="mustStayVisible">Jeu installé sur ce PC, ou demandé en ligne de commande : jamais masqué.</param>
public static void Apply(LauncherCatalog launcher, CatalogDocument catalog, IReadOnlyCollection<string> builtInThemes,
Func<string?, string?> localImage, Func<LauncherConfig, bool> mustStayVisible)
{
var declared = launcher.DeclaredGames;
foreach (var game in declared)
{
Restore(game);
if (!catalog.Games.TryGetValue(game.GameId, out var entry)) continue;
if (entry.GameName is { } name) game.DisplayName = name;
if (entry.Tagline is { } tagline) game.Tagline = tagline;
if (entry.Description is { } description) game.Description = description;
if (entry.WebsiteUrl is { } website) game.WebsiteUrl = website;
if (entry.Links is { Count: > 0 } links)
game.Links = links.Select(l => new GameLink(l.Kind, l.Label ?? CatalogLinkKinds.DefaultLabel(l.Kind), l.Url)).ToList();
if (entry.Theme is { } theme)
{
if (builtInThemes.Contains(theme, StringComparer.OrdinalIgnoreCase)) game.Theme = theme;
else Log.Warn($"Catalogue : thème « {theme} » inconnu de cette version du launcher ({game.GameId}), ignoré.");
}
if (localImage(entry.Icon) is { } icon) game.Icon = icon;
if (localImage(entry.Background) is { } background) game.Background = background;
if (entry.UpdateCheckIntervalMinutes is { } interval) game.UpdateCheckIntervalMinutes = interval;
}
// Ordre : celui du catalogue, puis les autres jeux dans l'ordre de launcher.json.
var ordered = new List<LauncherConfig>();
foreach (var id in catalog.Launcher?.GameOrder ?? new List<string>())
if (declared.FirstOrDefault(g => string.Equals(g.GameId, id, StringComparison.OrdinalIgnoreCase)) is { } game && !ordered.Contains(game))
ordered.Add(game);
ordered.AddRange(declared.Where(g => !ordered.Contains(g)));
// Visibilité.
var visible = ordered.Where(g => !(catalog.Games.TryGetValue(g.GameId, out var entry) && entry.Hidden == true) || mustStayVisible(g))
.ToList();
if (visible.Count == 0) visible.Add(ordered[0]);
var launcherName = catalog.Launcher?.LauncherName ?? launcher.DeclaredLauncherName;
var defaultGame = catalog.Launcher?.DefaultGame is { } requested && visible.Any(g => string.Equals(g.GameId, requested, StringComparison.OrdinalIgnoreCase))
? requested
: launcher.DeclaredDefaultGameId;
launcher.ApplyDisplay(launcherName, defaultGame, visible);
}
/// <summary>Rétablit les valeurs de launcher.json (enregistrées à la première application).</summary>
private static void Restore(LauncherConfig game)
{
var baseline = Baselines.GetValue(game, g => new Baseline(g.Tagline, g.WebsiteUrl, g.Theme, g.Icon, g.Background, g.UpdateCheckIntervalMinutes));
game.DisplayName = null;
game.Description = null;
game.Links = Array.Empty<GameLink>();
game.Tagline = baseline.Tagline;
game.WebsiteUrl = baseline.WebsiteUrl;
game.Theme = baseline.Theme;
game.Icon = baseline.Icon;
game.Background = baseline.Background;
game.UpdateCheckIntervalMinutes = baseline.UpdateCheckIntervalMinutes;
}
}
@@ -0,0 +1,190 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Services;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Catalog;
/// <summary>
/// Catalogue des jeux côté joueur : catalog.json (statique, publié depuis le menu d'administration)
/// téléchargé, contrôlé et borné (<see cref="CatalogRules"/> : une valeur invalide est ignorée), puis
/// mis en cache dans le dossier des données du launcher. Sans réseau : dernier cache, sinon les
/// valeurs de launcher.json. Images (icône, fond) : PNG ou JPEG du serveur du catalogue, 4 Mo au plus,
/// téléchargées une fois puis conservées en cache.
/// </summary>
public sealed class CatalogService
{
public const long MaxImageBytes = 4 * 1024 * 1024;
private const string FolderName = "catalog";
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
private readonly Uri _catalogUrl;
/// <summary>Téléchargements d'images en cours (une même image n'est téléchargée qu'une fois à la fois).</summary>
private readonly ConcurrentDictionary<string, Lazy<Task<string?>>> _imageDownloads = new(StringComparer.OrdinalIgnoreCase);
public CatalogService(HttpDownloader http, string dataDirectory, Uri catalogUrl)
: this(http.GetBytesAsync, dataDirectory, catalogUrl) { }
/// <param name="download">Téléchargement borné (tests : téléchargement simulé).</param>
public CatalogService(Func<Uri, long, CancellationToken, Task<byte[]>> download, string dataDirectory, Uri catalogUrl)
{
_download = download;
_catalogUrl = catalogUrl;
Directory = Path.Combine(dataDirectory, FolderName);
CachePath = Path.Combine(Directory, "catalog.json");
ImagesDirectory = Path.Combine(Directory, "images");
}
public string Directory { get; }
public string CachePath { get; }
public string ImagesDirectory { get; }
/// <summary>Catalogue du cache (null : absent ou illisible).</summary>
public CatalogDocument? LoadCached()
{
try
{
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > CatalogRules.MaxFileBytes) return null;
return Parse(File.ReadAllBytes(CachePath), out _);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or InvalidDataException)
{
Log.Warn($"Catalogue en cache illisible : {ex.Message}");
return null;
}
}
/// <summary>
/// Télécharge le catalogue, le contrôle et le met en cache. Null si le serveur ne répond pas ou si
/// le fichier est inutilisable (le cache est alors conservé tel quel).
/// </summary>
public async Task<CatalogDocument?> FetchAsync(CancellationToken ct = default)
{
try
{
var bytes = await _download(_catalogUrl, CatalogRules.MaxFileBytes, ct).ConfigureAwait(false);
var catalog = Parse(bytes, out var errors);
if (catalog is null) return null;
foreach (var error in errors.Take(10)) Log.Warn($"Catalogue : valeur ignorée — {error}");
System.IO.Directory.CreateDirectory(Directory);
FileUtil.WriteAllTextAtomic(CachePath, JsonSerializer.Serialize(catalog, ApiJson.Options));
return catalog;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or IOException or UnauthorizedAccessException or
InvalidDataException or JsonException or OperationCanceledException)
{
Log.Warn($"Catalogue des jeux indisponible ({_catalogUrl.Host}) : {ex.Message}");
return null;
}
}
/// <summary>Lecture + contrôle (valeurs invalides retirées). Null si le format est inconnu.</summary>
private CatalogDocument? Parse(byte[] bytes, out List<string> errors)
{
errors = new List<string>();
var raw = JsonSerializer.Deserialize<CatalogDocument>(Encoding.UTF8.GetString(bytes).TrimStart(''), ApiJson.Options)
?? throw new InvalidDataException("catalogue vide");
if (raw.Format != CatalogDocument.FormatName || raw.Version != CatalogDocument.CurrentVersion)
{
Log.Warn($"Catalogue ignoré : format « {raw.Format} » version {raw.Version} non pris en charge par cette version du launcher.");
return null;
}
return CatalogRules.Normalize(raw, _catalogUrl.Host, errors);
}
// ------------------------------------------------------------------ Images
/// <summary>Image déjà en cache (null sinon) : utilisable immédiatement, même hors ligne.</summary>
public string? CachedImage(string? url)
{
if (ImagePath(url) is not { } path) return null;
return File.Exists(path) ? path : null;
}
/// <summary>
/// Image en cache, sinon téléchargée (PNG ou JPEG contrôlé). Null si indisponible. Appels simultanés
/// pour la même image (démarrage, puis arrière-plan) : un seul téléchargement, partagé.
/// </summary>
public Task<string?> GetImageAsync(string? url)
{
if (ImagePath(url) is not { } path) return Task.FromResult<string?>(null);
if (File.Exists(path)) return Task.FromResult<string?>(path);
var download = _imageDownloads.GetOrAdd(path, _ => new Lazy<Task<string?>>(() => DownloadImageAsync(url!, path)));
return download.Value;
}
private async Task<string?> DownloadImageAsync(string url, string path)
{
var temp = $"{path}.{Guid.NewGuid():N}.tmp";
try
{
var bytes = await _download(new Uri(url), MaxImageBytes, CancellationToken.None).ConfigureAwait(false);
if (!IsPngOrJpeg(bytes)) throw new InvalidDataException("ce n'est pas une image PNG ou JPEG");
System.IO.Directory.CreateDirectory(ImagesDirectory);
await File.WriteAllBytesAsync(temp, bytes).ConfigureAwait(false);
File.Move(temp, path, overwrite: true);
return path;
}
catch (Exception ex) when (ex is HttpRequestException or TimeoutException or IOException or UnauthorizedAccessException or
InvalidDataException or OperationCanceledException)
{
FileUtil.TryDeleteFile(temp);
Log.Warn($"Image du catalogue indisponible ({url}) : {ex.Message}");
return null;
}
finally
{
_imageDownloads.TryRemove(path, out _);
}
}
/// <summary>Supprime les images du cache qui ne sont plus utilisées par le catalogue.</summary>
public void PruneImages(CatalogDocument catalog)
{
try
{
if (!System.IO.Directory.Exists(ImagesDirectory)) return;
var used = catalog.Games.Values.SelectMany(g => new[] { g.Icon, g.Background })
.Select(ImagePath).Where(p => p is not null).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var file in System.IO.Directory.GetFiles(ImagesDirectory))
{
if (used.Contains(file)) continue;
// Fichier temporaire : peut-être un téléchargement en cours (supprimé s'il est ancien).
if (file.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) &&
DateTime.UtcNow - File.GetLastWriteTimeUtc(file) < TimeSpan.FromHours(1))
continue;
FileUtil.TryDeleteFile(file);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Nettoyage des images du catalogue : {ex.Message}");
}
}
/// <summary>Fichier du cache pour une image (nom = empreinte de l'adresse) ; null si l'adresse est refusée.</summary>
private string? ImagePath(string? url)
{
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, _catalogUrl.Host, StringComparison.OrdinalIgnoreCase) ||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
return null;
var extension = uri.AbsolutePath.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ? ".png" : ".jpg";
var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(uri.AbsoluteUri)))[..32].ToLowerInvariant();
return Path.Combine(ImagesDirectory, name + extension);
}
internal static bool IsPngOrJpeg(ReadOnlySpan<byte> data) =>
data.StartsWith(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }) ||
data.StartsWith(new byte[] { 0xFF, 0xD8, 0xFF });
}
@@ -13,4 +13,9 @@
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<!-- Catalogue des jeux (catalog.json) : format et règles partagés avec la Launcher API. -->
<ProjectReference Include="..\GameLauncher.Contracts\GameLauncher.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -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;
@@ -15,7 +15,7 @@ namespace GameLauncher.Core.Models;
/// </summary>
public sealed class LauncherCatalog
{
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl" };
private static readonly string[] RootOnlyProperties = { "games", "launcherId", "launcherName", "defaultGame", "launcherUpdateUrl", "dataFolder", "apiUrl", "catalogUrl" };
/// <summary>Nom par défaut du dossier des données du launcher (%AppData%\&lt;Publisher&gt;\GameLauncher).</summary>
public const string DefaultDataFolderName = "GameLauncher";
@@ -34,21 +34,34 @@ public sealed class LauncherCatalog
Publisher = publisher;
DefaultGameId = defaultGameId;
Games = games;
DeclaredGames = games;
DeclaredLauncherName = launcherName;
DeclaredDefaultGameId = defaultGameId;
IsMultiGameFormat = isMultiGameFormat;
}
/// <summary>Identifiant technique du launcher (dossier de données, instance unique).</summary>
public string LauncherId { get; }
/// <summary>Nom affiché dans la barre de titre lorsque plusieurs jeux sont proposés.</summary>
public string LauncherName { get; }
/// <summary>Nom affiché dans la barre de titre lorsque plusieurs jeux sont proposés (modifiable par le catalogue).</summary>
public string LauncherName { get; private set; }
public string Publisher { get; }
/// <summary>Jeu sélectionné au premier lancement (sinon le premier de la liste).</summary>
public string? DefaultGameId { get; }
public string? DefaultGameId { get; private set; }
public IReadOnlyList<LauncherConfig> Games { get; }
/// <summary>Jeux affichés, dans l'ordre (le catalogue peut changer l'ordre et masquer des jeux non installés).</summary>
public IReadOnlyList<LauncherConfig> Games { get; private set; }
/// <summary>Tous les jeux déclarés dans launcher.json, y compris ceux masqués par le catalogue.</summary>
public IReadOnlyList<LauncherConfig> DeclaredGames { get; }
/// <summary>Nom du launcher selon launcher.json (avant le catalogue).</summary>
public string DeclaredLauncherName { get; }
/// <summary>Jeu par défaut selon launcher.json (avant le catalogue).</summary>
public string? DeclaredDefaultGameId { get; }
/// <summary>Vrai si launcher.json utilise le tableau "games" (même avec un seul jeu).</summary>
public bool IsMultiGameFormat { get; }
@@ -75,6 +88,24 @@ public sealed class LauncherCatalog
/// <summary>Raison pour laquelle « apiUrl » a été ignorée (valeur invalide), sinon null.</summary>
public string? ApiUrlError { get; private set; }
/// <summary>
/// Adresse du catalogue des jeux (catalog.json : informations modifiables publiées depuis le menu
/// d'administration), ex. « https://launcher.lanisys.fr/catalog/catalog.json ». Facultatif. Comme
/// <see cref="ApiUrl"/>, le launcher publié ne la lit que dans le launcher.json intégré à l'exe.
/// </summary>
public string? CatalogUrl { get; private set; }
/// <summary>Raison pour laquelle « catalogUrl » a été ignorée (valeur invalide), sinon null.</summary>
public string? CatalogUrlError { get; private set; }
/// <summary>Affichage modifié par le catalogue (voir Catalog.CatalogMerger).</summary>
internal void ApplyDisplay(string launcherName, string? defaultGameId, IReadOnlyList<LauncherConfig> games)
{
LauncherName = launcherName;
DefaultGameId = defaultGameId;
Games = games;
}
/// <summary>
/// Nom du dossier des données du launcher et de sa clé de registre (« dataFolder » dans
/// launcher.json, « GameLauncher » par défaut). Indépendant de <see cref="LauncherId"/>, qui
@@ -215,10 +246,11 @@ public sealed class LauncherCatalog
? $@"Local\{Publisher}.{Games[0].GameId}.Launcher"
: $@"Local\{Publisher}.{LauncherId}";
/// <summary>Jeu déclaré dans launcher.json (même s'il est masqué par le catalogue).</summary>
public LauncherConfig? Find(string? gameId) =>
string.IsNullOrWhiteSpace(gameId)
? null
: Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase));
: DeclaredGames.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase));
public static LauncherCatalog Load(string path)
{
@@ -255,6 +287,8 @@ public sealed class LauncherCatalog
LauncherUpdateUrl = ReadUpdateUrl(root),
ApiUrl = ReadApiUrl(root, out var singleApiUrlError),
ApiUrlError = singleApiUrlError,
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var singleCatalogError),
CatalogUrlError = singleCatalogError,
DataFolderName = ReadDataFolder(root)
};
}
@@ -308,6 +342,8 @@ public sealed class LauncherCatalog
LauncherUpdateUrl = ReadUpdateUrl(root),
ApiUrl = ReadApiUrl(root, out var apiUrlError),
ApiUrlError = apiUrlError,
CatalogUrl = ReadTrustedUrl(root, "catalogUrl", "https://launcher.exemple.fr/catalog/catalog.json", out var catalogError),
CatalogUrlError = catalogError,
DataFolderName = ReadDataFolder(root)
};
}
@@ -333,6 +369,24 @@ public sealed class LauncherCatalog
}
/// <summary>« apiUrl » : adresse https absolue, sans paramètres (http accepté seulement en local, pour les tests).</summary>
/// <summary>Adresse https absolue, sans paramètres ni identifiants (http autorisé seulement en local) ; null si absente ou invalide.</summary>
private static string? ReadTrustedUrl(JsonObject root, string name, string example, out string? error)
{
error = null;
var node = Get(root, name);
if (node is null) return null;
var url = node is JsonValue value && value.TryGetValue<string>(out var text) ? text : null;
if (url is not null && string.IsNullOrWhiteSpace(url)) return null;
if (url is null || !Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) ||
!(uri.Scheme == Uri.UriSchemeHttps || (uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)) ||
!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || !string.IsNullOrEmpty(uri.UserInfo))
{
error = $"launcher.json : {name} ignorée, elle doit être une adresse https:// absolue sans paramètres, ex. \"{example}\".";
return null;
}
return uri.AbsoluteUri;
}
private static string? ReadApiUrl(JsonObject root, out string? error)
{
error = null;
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Models;
@@ -47,6 +48,27 @@ public sealed class LauncherConfig
/// <summary>Image de fond remplaçant celle du thème (image, chemin relatif au launcher). Facultatif.</summary>
public string? Background { get; set; }
// ------------------------------------------------------------------ Catalogue (affichage modifiable)
/// <summary>
/// Nom AFFICHÉ, donné par le catalogue. <see cref="GameName"/> reste le nom de launcher.json : celui
/// du raccourci du Bureau, de « Applications installées » et du dossier des données du jeu.
/// </summary>
[JsonIgnore]
public string? DisplayName { get; set; }
/// <summary>Nom à afficher : celui du catalogue, sinon celui de launcher.json.</summary>
[JsonIgnore]
public string ShownName => string.IsNullOrWhiteSpace(DisplayName) ? GameName : DisplayName;
/// <summary>Présentation du jeu (catalogue, texte brut de plusieurs lignes).</summary>
[JsonIgnore]
public string? Description { get; set; }
/// <summary>Liens affichés sous la présentation (catalogue).</summary>
[JsonIgnore]
public IReadOnlyList<GameLink> Links { get; set; } = Array.Empty<GameLink>();
/// <summary>
/// Dossier des données du jeu Unity : « Société\Produit » des Player Settings de Unity
/// (ex. "LanisysGames\\Exovival Zombie Reborn"). Sert à ouvrir, depuis les paramètres du jeu,
@@ -127,3 +149,7 @@ public sealed class LauncherConfig
part.Trim() == part && part.IndexOfAny(invalid) < 0);
}
}
/// <summary>Lien affiché sous la présentation d'un jeu (Discord, YouTube…), adresse https contrôlée.</summary>
/// <param name="Kind">Type de lien (icône intégrée) : website, discord, youtube, x, twitch, steam, other.</param>
public sealed record GameLink(string Kind, string Label, string Url);
@@ -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;
@@ -11,8 +11,15 @@ namespace GameLauncher.Core.Services;
public sealed class HttpDownloader : IDisposable
{
private static readonly TimeSpan StallTimeout = TimeSpan.FromSeconds(30);
private const int MaxSameHostRedirects = 3;
private readonly HttpClient _http;
/// <summary>
/// Client SANS redirection automatique (catalogue, images) : une redirection n'est suivie que vers le
/// même serveur, après vérification, et aucune requête ne part vers un autre serveur.
/// </summary>
private readonly HttpClient _strictHttp;
public HttpDownloader(string userAgent)
{
var handler = new SocketsHttpHandler
@@ -25,6 +32,15 @@ public sealed class HttpDownloader : IDisposable
};
_http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
_http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
_strictHttp = new HttpClient(new SocketsHttpHandler
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.None,
ConnectTimeout = TimeSpan.FromSeconds(15),
PooledConnectionLifetime = TimeSpan.FromMinutes(5)
}) { Timeout = Timeout.InfiniteTimeSpan };
_strictHttp.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
}
/// <summary>Taille maximale par défaut d'un document texte (manifest du jeu).</summary>
@@ -79,6 +95,64 @@ public sealed class HttpDownloader : IDisposable
}
}
/// <summary>
/// Télécharge un petit fichier en mémoire (catalogue, image) sans passer par les caches, en
/// refusant toute redirection vers un autre serveur ou vers une adresse non https.
/// Au-delà de <paramref name="maxBytes"/> octets, le téléchargement est interrompu.
/// </summary>
public async Task<byte[]> GetBytesAsync(Uri uri, long maxBytes, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
try
{
var current = uri;
HttpResponseMessage response;
for (var redirects = 0; ; redirects++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, current);
request.Headers.CacheControl = new CacheControlHeaderValue { NoCache = true, NoStore = true };
request.Headers.Pragma.ParseAdd("no-cache");
response = await _strictHttp.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
.ConfigureAwait(false);
var status = (int)response.StatusCode;
if (status is < 300 or > 399 || status == 304) break;
// Redirection : suivie seulement vers le même serveur (hôte, schéma, port), quelques fois au plus.
var location = response.Headers.Location;
response.Dispose();
var next = location is null ? null : location.IsAbsoluteUri ? location : new Uri(current, location);
if (next is null || redirects >= MaxSameHostRedirects ||
!string.Equals(next.Host, uri.Host, StringComparison.OrdinalIgnoreCase) ||
next.Scheme != uri.Scheme || next.Port != uri.Port)
throw new InvalidDataException($"Redirection refusée ({uri.Host} → {next?.Host ?? "?"}).");
current = next;
}
using var _ = response;
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength > maxBytes)
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false);
using var buffer = new MemoryStream();
var chunk = new byte[81920];
int read;
while ((read = await stream.ReadAsync(chunk, timeout.Token).ConfigureAwait(false)) > 0)
{
if (buffer.Length + read > maxBytes)
throw new InvalidDataException($"Réponse du serveur trop volumineuse ({uri.AbsolutePath}).");
buffer.Write(chunk, 0, read);
}
return buffer.ToArray();
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException($"Le serveur ne répond pas ({uri.Host}).");
}
}
/// <summary>
/// Télécharge <paramref name="uri"/> vers <paramref name="destination"/>.
/// Si <paramref name="allowResume"/> est vrai et qu'un fichier partiel existe, le
@@ -155,5 +229,9 @@ public sealed class HttpDownloader : IDisposable
throw new IOException($"Téléchargement incomplet ({Format.Bytes(position)} sur {Format.Bytes(expectedSize)}).");
}
public void Dispose() => _http.Dispose();
public void Dispose()
{
_http.Dispose();
_strictHttp.Dispose();
}
}
@@ -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.Catalog;
/// <summary>
/// Module « Catalogue des jeux » : page « Jeux » du menu d'administration (informations modifiables
/// des jeux déclarés dans launcher.json, publiées dans catalog.json). Permission games.edit.
/// </summary>
public sealed class CatalogAdminModule : ILauncherModule
{
public string Id => "catalog";
public string DisplayName => "Catalogue des jeux";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
GamesPanelViewModel.PanelId,
"Jeux",
"Ce que le launcher affiche pour chaque jeu : nom, accroche, présentation, liens, apparence, ordre. " +
"Les jeux eux-mêmes restent déclarés dans launcher.json.",
"Gamepad",
AdminPermissions.GamesEdit,
Order: 10,
context => new GamesPanelViewModel(context)));
}
@@ -0,0 +1,311 @@
using System.Collections.ObjectModel;
using System.Globalization;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Models;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Catalog;
/// <summary>Choix d'une liste (null : valeur par défaut de launcher.json).</summary>
public sealed record Choice(string? Value, string Label)
{
/// <summary>Texte affiché par les listes déroulantes.</summary>
public override string ToString() => Label;
}
/// <summary>Partie modifiable de la page « Jeux » (réglages du launcher ou un jeu).</summary>
public abstract class CatalogEditor : ObservableObject
{
private readonly Action _changed;
protected CatalogEditor(Action changed) => _changed = changed;
public abstract string Title { get; }
public abstract string Subtitle { get; }
public abstract string IconKey { get; }
/// <summary>Modifié depuis la dernière publication (pastille dans la liste).</summary>
public bool IsDirty
{
get => _isDirty;
internal set => SetProperty(ref _isDirty, value);
}
private bool _isDirty;
protected 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;
}
protected void Changed() => _changed();
protected static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
}
/// <summary>Réglages du launcher : nom, jeu affiché au premier lancement.</summary>
public sealed class LauncherEditor : CatalogEditor
{
private string _launcherName = "";
private Choice _defaultGame;
public LauncherEditor(string declaredName, string? declaredDefault, IReadOnlyList<LauncherConfig> games, Action changed) : base(changed)
{
DeclaredName = declaredName;
GameChoices = new[] { new Choice(null, declaredDefault is null ? "Par défaut (premier jeu)" : $"Par défaut ({declaredDefault})") }
.Concat(games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})"))).ToList();
_defaultGame = GameChoices[0];
}
public override string Title => "Launcher";
public override string Subtitle => "Nom, jeu affiché au démarrage, ordre des jeux";
public override string IconKey => "Tune";
public string DeclaredName { get; }
public string DefaultNameHint => $"Vide : « {DeclaredName} » (launcher.json).";
public string LauncherName
{
get => _launcherName;
set => Set(ref _launcherName, value ?? "");
}
public IReadOnlyList<Choice> GameChoices { get; }
public Choice DefaultGame
{
get => _defaultGame;
set
{
if (value is not null) Set(ref _defaultGame, value); // null : liste de l'écran en cours de remplacement
}
}
internal void Load(CatalogLauncher? launcher)
{
_launcherName = launcher?.LauncherName ?? "";
_defaultGame = GameChoices.FirstOrDefault(c => c.Value is not null &&
string.Equals(c.Value, launcher?.DefaultGame, StringComparison.OrdinalIgnoreCase)) ?? GameChoices[0];
OnPropertyChanged(nameof(LauncherName));
OnPropertyChanged(nameof(DefaultGame));
}
internal CatalogLauncher? ToCatalog(IReadOnlyList<string> order, IReadOnlyList<string> declaredOrder)
{
var name = Clean(LauncherName);
var gameOrder = order.SequenceEqual(declaredOrder, StringComparer.OrdinalIgnoreCase) ? null : order.ToList();
return name is null && DefaultGame.Value is null && gameOrder is null
? null
: new CatalogLauncher { LauncherName = name, DefaultGame = DefaultGame.Value, GameOrder = gameOrder };
}
}
/// <summary>Un jeu déclaré dans launcher.json, et ses informations modifiables.</summary>
public sealed class GameEditor : CatalogEditor
{
private string _gameName = "", _tagline = "", _description = "", _websiteUrl = "", _icon = "", _background = "", _interval = "";
private Choice _theme;
private bool _hidden;
public GameEditor(LauncherConfig declared, IReadOnlyList<string> themes, Action changed) : base(changed)
{
Declared = declared;
_builtInThemeChoices = new[] { new Choice(null, $"Par défaut ({ThemeOf(declared)})") }
.Concat(themes.Select(t => new Choice(t, t))).ToList();
ThemeChoices = _builtInThemeChoices;
_theme = ThemeChoices[0];
LinkKinds = CatalogLinkKinds.All.Select(k => new Choice(k, CatalogLinkKinds.DefaultLabel(k))).ToList();
AddLinkCommand = new RelayCommand(AddLink, () => Links.Count < CatalogRules.MaxLinks);
Links.CollectionChanged += (_, _) =>
{
AddLinkCommand.RaiseCanExecuteChanged();
Changed();
};
}
public LauncherConfig Declared { get; }
public string GameId => Declared.GameId;
public override string Title => string.IsNullOrWhiteSpace(GameName) ? Declared.GameName : GameName.Trim();
public override string Subtitle => Hidden ? $"{GameId} · masqué" : GameId;
public override string IconKey => Hidden ? "EyeOffOutline" : "Gamepad";
// ------------------------------------------------------------------ Valeurs (vide : launcher.json)
public string GameName
{
get => _gameName;
set
{
if (Set(ref _gameName, value ?? "")) OnPropertyChanged(nameof(Title));
}
}
public string Tagline { get => _tagline; set => Set(ref _tagline, value ?? ""); }
public string Description { get => _description; set => Set(ref _description, value ?? ""); }
public string WebsiteUrl { get => _websiteUrl; set => Set(ref _websiteUrl, value ?? ""); }
public string Icon { get => _icon; set => Set(ref _icon, value ?? ""); }
public string Background { get => _background; set => Set(ref _background, value ?? ""); }
/// <summary>Minutes entre deux recherches de mise à jour (vide : launcher.json ; 0 : désactivé).</summary>
public string UpdateInterval { get => _interval; set => Set(ref _interval, value ?? ""); }
private readonly IReadOnlyList<Choice> _builtInThemeChoices;
/// <summary>Thèmes intégrés (+ celui du catalogue publié s'il est inconnu de ce launcher, pour ne pas l'effacer en silence).</summary>
public IReadOnlyList<Choice> ThemeChoices { get; private set; }
public Choice Theme
{
get => _theme;
set
{
// null : liste des thèmes remplacée à l'écran (rechargement), pas un choix de l'administrateur.
if (value is not null) Set(ref _theme, value);
}
}
public bool Hidden
{
get => _hidden;
set
{
if (!Set(ref _hidden, value)) return;
OnPropertyChanged(nameof(Subtitle));
OnPropertyChanged(nameof(IconKey));
}
}
public ObservableCollection<LinkEditor> Links { get; } = new();
public IReadOnlyList<Choice> LinkKinds { get; }
public RelayCommand AddLinkCommand { get; }
// ------------------------------------------------------------------ Valeurs de launcher.json (rappel)
public string NameHint => $"Vide : « {Declared.GameName} ». Le raccourci et « Applications installées » gardent ce nom.";
public string TaglineHint => string.IsNullOrWhiteSpace(Declared.Tagline) ? "Vide : aucune accroche." : $"Vide : « {Declared.Tagline} ».";
public string WebsiteHint => string.IsNullOrWhiteSpace(Declared.WebsiteUrl) ? "Adresse https. Vide : aucun site." : $"Adresse https. Vide : {Declared.WebsiteUrl}";
public string IntervalHint => $"0 : désactivé, ou {CatalogRules.MinUpdateInterval} à {CatalogRules.MaxUpdateInterval} minutes. Vide : {Declared.UpdateCheckIntervalMinutes} (launcher.json).";
public string ImageHint => "Image .png ou .jpg (4 Mo au plus) déposée sur le serveur du catalogue, ex. https://…/catalog/images/fond.jpg. " +
"Les launchers gardent l'image en cache : pour la changer, déposez-la sous un NOUVEAU nom. Vide : image de launcher.json.";
private static string ThemeOf(LauncherConfig config) => string.IsNullOrWhiteSpace(config.Theme) ? "default" : config.Theme;
private void AddLink()
{
if (Links.Count >= CatalogRules.MaxLinks) return;
Links.Add(new LinkEditor(LinkKinds, CatalogLinkKinds.Website, "", "", Changed, RemoveLink));
}
private void RemoveLink(LinkEditor link) => Links.Remove(link);
internal void Load(CatalogGame? game)
{
_gameName = game?.GameName ?? "";
_tagline = game?.Tagline ?? "";
_description = game?.Description ?? "";
_websiteUrl = game?.WebsiteUrl ?? "";
_icon = game?.Icon ?? "";
_background = game?.Background ?? "";
_interval = game?.UpdateCheckIntervalMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
_hidden = game?.Hidden == true;
ThemeChoices = _builtInThemeChoices;
_theme = ThemeChoices.FirstOrDefault(c => c.Value is not null && string.Equals(c.Value, game?.Theme, StringComparison.OrdinalIgnoreCase))
?? ThemeChoices[0];
if (_theme.Value is null && game?.Theme is { } unknown)
{
// Publié par une autre version du launcher : conservé (les launchers qui ne le connaissent pas l'ignorent).
_theme = new Choice(unknown, $"{unknown} (inconnu de ce launcher)");
ThemeChoices = _builtInThemeChoices.Append(_theme).ToList();
}
Links.Clear();
foreach (var link in game?.Links ?? new List<CatalogLink>())
Links.Add(new LinkEditor(LinkKinds, link.Kind, link.Label ?? "", link.Url, Changed, RemoveLink));
foreach (var name in new[] { nameof(GameName), nameof(Tagline), nameof(Description), nameof(WebsiteUrl), nameof(Icon), nameof(Background),
nameof(UpdateInterval), nameof(Hidden), nameof(ThemeChoices), nameof(Theme), nameof(Title), nameof(Subtitle), nameof(IconKey) })
OnPropertyChanged(name);
}
/// <summary>Valeurs saisies (contrôlées ensuite par <see cref="CatalogRules"/>) ; null si tout est par défaut.</summary>
internal CatalogGame? ToCatalog(List<string> errors)
{
int? interval = null;
if (!string.IsNullOrWhiteSpace(UpdateInterval))
{
if (int.TryParse(UpdateInterval.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var minutes)) interval = minutes;
else errors.Add($"Jeu « {GameId} » : intervalle de mise à jour : nombre de minutes attendu.");
}
var links = Links.Where(l => !l.IsEmpty)
.Select(l => new CatalogLink { Kind = l.Kind.Value ?? CatalogLinkKinds.Other, Label = Clean(l.Label), Url = l.Url.Trim() })
.ToList();
var game = new CatalogGame
{
GameName = Clean(GameName),
Tagline = Clean(Tagline),
Description = Clean(Description),
WebsiteUrl = Clean(WebsiteUrl),
Links = links.Count == 0 ? null : links,
Theme = Theme.Value,
Icon = Clean(Icon),
Background = Clean(Background),
UpdateCheckIntervalMinutes = interval,
Hidden = Hidden ? true : null
};
return game is { GameName: null, Tagline: null, Description: null, WebsiteUrl: null, Links: null, Theme: null, Icon: null,
Background: null, UpdateCheckIntervalMinutes: null, Hidden: null }
? null
: game;
}
}
/// <summary>Lien d'un jeu (Discord, YouTube…).</summary>
public sealed class LinkEditor : ObservableObject
{
private readonly Action _changed;
private Choice _kind;
private string _label, _url;
public LinkEditor(IReadOnlyList<Choice> kinds, string kind, string label, string url, Action changed, Action<LinkEditor> remove)
{
Kinds = kinds;
_kind = kinds.FirstOrDefault(k => k.Value == kind) ?? kinds[^1];
_label = label;
_url = url;
_changed = changed;
RemoveCommand = new RelayCommand(() => remove(this));
}
public IReadOnlyList<Choice> Kinds { get; }
public Choice Kind
{
get => _kind;
set
{
if (value is not null && SetProperty(ref _kind, value)) _changed(); // null : liste de l'écran en cours de remplacement
}
}
public string Label
{
get => _label;
set
{
if (SetProperty(ref _label, value ?? "")) _changed();
}
}
public string Url
{
get => _url;
set
{
if (SetProperty(ref _url, value ?? "")) _changed();
}
}
public RelayCommand RemoveCommand { get; }
internal bool IsEmpty => string.IsNullOrWhiteSpace(Url) && string.IsNullOrWhiteSpace(Label);
}
@@ -0,0 +1,269 @@
using System.Collections.ObjectModel;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Catalog;
/// <summary>
/// Page « Jeux » : informations modifiables des jeux déclarés dans launcher.json, publiées dans le
/// catalogue (catalog.json) que les launchers des joueurs lisent au démarrage.
/// • une liste : « Launcher » (nom, jeu par défaut) puis les jeux, dans l'ordre d'affichage (↑ ↓) ;
/// • champ vide = valeur de launcher.json (rappelée sous le champ) ;
/// • « Publier » : contrôle complet (mêmes règles que le serveur), requête signée, révision attendue
/// (si quelqu'un a publié entre-temps : rechargement proposé, rien n'est écrasé).
/// </summary>
public sealed class GamesPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "catalog.games";
private CatalogState? _state;
private CatalogEditor? _selected;
private string? _statusMessage;
private bool _loading;
private string _publishedSnapshot = "";
private bool _hasChanges;
private bool _hasConflict;
public GamesPanelViewModel(IAdminContext context) : base(context)
{
Launcher = new LauncherEditor(context.LauncherName, context.DefaultGameId, context.Games, OnEdited);
foreach (var game in context.Games) Games.Add(new GameEditor(game, context.Themes, OnEdited));
RebuildItems(Launcher);
PublishCommand = new AsyncRelayCommand(PublishAsync, () => !IsBusy && HasChanges && PublishingEnabled);
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy);
MoveUpCommand = new RelayCommand(() => Move(-1), () => CanMove(-1));
MoveDownCommand = new RelayCommand(() => Move(+1), () => CanMove(+1));
}
public LauncherEditor Launcher { get; }
/// <summary>Jeux dans l'ordre d'affichage du launcher.</summary>
public ObservableCollection<GameEditor> Games { get; } = new();
/// <summary>Liste affichée : « Launcher », puis les jeux.</summary>
public ObservableCollection<CatalogEditor> Items { get; } = new();
public CatalogEditor? Selected
{
get => _selected;
set
{
if (!SetProperty(ref _selected, value)) return;
MoveUpCommand.RaiseCanExecuteChanged();
MoveDownCommand.RaiseCanExecuteChanged();
}
}
public bool PublishingEnabled => _state?.PublishingEnabled == true;
public bool ShowPublishingDisabled => _state is not null && !_state.PublishingEnabled;
public string? PublicUrl => _state?.PublicUrl;
/// <summary>« Révision 12, publiée le 31/10/2026 à 08:42 » / « Jamais publié ».</summary>
public string RevisionText => _state?.Catalog is not { } catalog ? "" :
catalog.Revision == 0 ? "Jamais publié : le launcher utilise les valeurs de launcher.json."
: $"Révision {catalog.Revision}" + (catalog.UpdatedAt is { } at ? $", publiée le {at.ToLocalTime():dd/MM/yyyy à HH:mm}" : "") + ".";
public bool HasChanges
{
get => _hasChanges;
private set
{
if (!SetProperty(ref _hasChanges, value)) return;
PublishCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Quelqu'un a publié entre-temps : il faut recharger.</summary>
public bool HasConflict
{
get => _hasConflict;
private set => SetProperty(ref _hasConflict, value);
}
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
public AsyncRelayCommand PublishCommand { get; }
public AsyncRelayCommand ReloadCommand { get; }
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
/// <summary>Faux pendant un chargement ou une publication : la saisie attend la réponse du serveur.</summary>
public bool IsEditable => !IsBusy;
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
PublishCommand.RaiseCanExecuteChanged();
ReloadCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Chargement
public Task LoadAsync() => RunAsync(async () =>
{
var state = await Context.Session.GetCatalogAsync();
Apply(state);
StatusMessage = null;
});
private async Task ReloadAsync()
{
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les modifications non publiées seront perdues : la page affichera le catalogue actuellement publié.",
"Abandonner et recharger"))
return;
await LoadAsync();
}
/// <summary>Affiche un catalogue (chargé ou publié) et en fait la référence « sans modification ».</summary>
private void Apply(CatalogState state)
{
_loading = true;
try
{
_state = state;
var catalog = state.Catalog;
Launcher.Load(catalog.Launcher);
foreach (var game in Games) game.Load(catalog.Games.TryGetValue(game.GameId, out var entry) ? entry : null);
// Ordre : celui du catalogue, puis les autres jeux dans l'ordre de launcher.json.
var ordered = new List<GameEditor>();
foreach (var id in catalog.Launcher?.GameOrder ?? new List<string>())
if (Games.FirstOrDefault(g => string.Equals(g.GameId, id, StringComparison.OrdinalIgnoreCase)) is { } game && !ordered.Contains(game))
ordered.Add(game);
ordered.AddRange(Context.Games.Select(d => Games.First(g => g.Declared == d)).Where(g => !ordered.Contains(g)));
var selected = Selected;
Games.Clear();
foreach (var game in ordered) Games.Add(game);
RebuildItems(selected);
}
finally
{
_loading = false;
}
_publishedSnapshot = Snapshot(BuildCatalog(new List<string>()));
HasConflict = false;
RefreshDirty();
foreach (var name in new[] { nameof(PublishingEnabled), nameof(ShowPublishingDisabled), nameof(PublicUrl), nameof(RevisionText) })
OnPropertyChanged(name);
PublishCommand.RaiseCanExecuteChanged();
}
// ------------------------------------------------------------------ Publication
private Task PublishAsync() => RunAsync(async () =>
{
if (_state is not { } state) return;
var errors = new List<string>();
var draft = BuildCatalog(errors);
var imageHost = Uri.TryCreate(state.PublicUrl, UriKind.Absolute, out var url) ? url.Host : null;
var normalized = CatalogRules.Normalize(draft, imageHost, errors);
if (errors.Count == 0 && CatalogRules.PublishedSize(normalized) > CatalogRules.MaxFileBytes)
errors.Add($"Catalogue trop volumineux (au plus {CatalogRules.MaxFileBytes / 1024} Ko) : raccourcissez les descriptions.");
if (errors.Count > 0)
{
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors.Distinct().Take(12)), ModuleMessageKind.Warning);
return;
}
try
{
var published = await Context.Session.PublishCatalogAsync(draft, state.Catalog.Revision);
Apply(published);
StatusMessage = $"Catalogue publié (révision {published.Catalog.Revision}). Les launchers l'appliquent à leur prochain démarrage.";
Log.Info($"Administration : catalogue publié (révision {published.Catalog.Revision}).");
}
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
{
HasConflict = true;
Context.Dialogs.ShowMessage("Publié entre-temps",
"Quelqu'un a publié le catalogue pendant vos modifications. Rechargez la page pour partir de sa version " +
"(vos modifications non publiées seront perdues : notez-les si besoin).", ModuleMessageKind.Warning);
}
});
/// <summary>Catalogue saisi (ordre affiché, champs non vides).</summary>
private CatalogDocument BuildCatalog(List<string> errors)
{
var games = new Dictionary<string, CatalogGame>(StringComparer.OrdinalIgnoreCase);
foreach (var game in Games)
if (game.ToCatalog(errors) is { } entry) games[game.GameId] = entry;
return new CatalogDocument
{
Launcher = Launcher.ToCatalog(Games.Select(g => g.GameId).ToList(), Context.Games.Select(g => g.GameId).ToList()),
Games = games
};
}
// ------------------------------------------------------------------ Modifications
private void OnEdited()
{
if (_loading) return;
RefreshDirty();
}
private void RefreshDirty()
{
var errors = new List<string>();
var current = BuildCatalog(errors);
HasChanges = Snapshot(current) != _publishedSnapshot || errors.Count > 0;
var published = string.IsNullOrEmpty(_publishedSnapshot) ? null : JsonSerializer.Deserialize<CatalogDocument>(_publishedSnapshot, ApiJson.Options);
Launcher.IsDirty = Snapshot(current.Launcher) != Snapshot(published?.Launcher);
foreach (var game in Games)
{
current.Games.TryGetValue(game.GameId, out var now);
CatalogGame? before = null;
published?.Games.TryGetValue(game.GameId, out before);
game.IsDirty = Snapshot(now) != Snapshot(before);
}
}
private bool CanMove(int delta) =>
Selected is GameEditor game && Games.IndexOf(game) is var index and >= 0 && index + delta >= 0 && index + delta < Games.Count;
private void Move(int delta)
{
if (Selected is not GameEditor game || !CanMove(delta)) return;
var index = Games.IndexOf(game);
Games.Move(index, index + delta);
RebuildItems(game);
OnEdited();
}
/// <summary>
/// Reconstruit la liste affichée puis resélectionne <paramref name="select"/> : la liste de l'écran
/// vide sa sélection quand ses éléments sont retirés, la notification la rétablit.
/// </summary>
private void RebuildItems(CatalogEditor? select)
{
Items.Clear();
Items.Add(Launcher);
foreach (var game in Games) Items.Add(game);
_selected = select is not null && Items.Contains(select) ? select : Items.FirstOrDefault();
OnPropertyChanged(nameof(Selected));
MoveUpCommand?.RaiseCanExecuteChanged();
MoveDownCommand?.RaiseCanExecuteChanged();
}
private static string Snapshot(object? value) => value is null ? "" : JsonSerializer.Serialize(value, ApiJson.Options);
}
@@ -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);
}
@@ -12,8 +12,18 @@ public sealed class AdminEnvironment
public required ModuleCatalog Modules { get; init; }
/// <summary>Jeux déclarés dans launcher.json (valeurs par défaut, avant le catalogue).</summary>
public required IReadOnlyList<LauncherConfig> Games { get; init; }
/// <summary>Nom du launcher selon launcher.json.</summary>
public string LauncherName { get; init; } = "Launcher";
/// <summary>Jeu par défaut selon launcher.json.</summary>
public string? DefaultGameId { get; init; }
/// <summary>Thèmes intégrés au launcher (seuls choix possibles dans le catalogue).</summary>
public IReadOnlyList<string> Themes { get; init; } = new[] { "default" };
public required IModuleDialogs Dialogs { get; init; }
public required IUiDispatcher Ui { get; init; }
@@ -120,6 +120,9 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
public AdminSession Session => _connection.Session;
public LauncherApiClient Client => _connection.Client;
public IReadOnlyList<LauncherConfig> Games => _shell.Environment.Games;
public string LauncherName => _shell.Environment.LauncherName;
public string? DefaultGameId => _shell.Environment.DefaultGameId;
public IReadOnlyList<string> Themes => _shell.Environment.Themes;
public IModuleDialogs Dialogs => _shell.Environment.Dialogs;
public string LauncherVersion => _shell.Environment.LauncherVersion;
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
@@ -11,9 +11,18 @@ public interface IAdminContext
LauncherApiClient Client { get; }
/// <summary>Jeux déclarés dans launcher.json (les seuls que l'administration peut modifier).</summary>
/// <summary>Jeux déclarés dans launcher.json (les seuls que l'administration peut modifier), valeurs par défaut.</summary>
IReadOnlyList<LauncherConfig> Games { get; }
/// <summary>Nom du launcher selon launcher.json.</summary>
string LauncherName { get; }
/// <summary>Jeu par défaut selon launcher.json.</summary>
string? DefaultGameId { get; }
/// <summary>Thèmes intégrés au launcher.</summary>
IReadOnlyList<string> Themes { get; }
IModuleDialogs Dialogs { get; }
string LauncherVersion { get; }
+168 -3
View File
@@ -5,12 +5,16 @@ using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Threading;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Catalog;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Services;
using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Platform;
using GameLauncher.Services;
using GameLauncher.ViewModels;
@@ -46,6 +50,8 @@ public partial class App : Application, ILauncherRestartHost
private HttpDownloader? _http;
private LauncherUpdateService? _updates;
private AdminWindowService? _admin;
private CatalogService? _catalogService;
private Task<CatalogDocument?>? _catalogFetch;
private string _launcherPath = "";
private Version _version = new(1, 0, 0);
@@ -150,6 +156,12 @@ public partial class App : Application, ILauncherRestartHost
}
}
// Catalogue des jeux (informations modifiables publiées depuis le menu d'administration) :
// dernier catalogue en cache appliqué tout de suite ; version à jour téléchargée pendant la
// vérification de mise à jour du launcher (pas de téléchargement pour --capture et --uninstall).
var catalogStarted = DateTime.UtcNow;
StartCatalog(catalog, args, download: args.CaptureDirectory is null && !args.Uninstall);
// 3. Mise à jour du launcher lui-même : au démarrage, avant la fenêtre principale
var normalStart = args.CaptureDirectory is null && !args.Uninstall && args.UpdatedFromPid is null;
if (normalStart)
@@ -165,6 +177,8 @@ public partial class App : Application, ILauncherRestartHost
}
}
await ApplyFreshCatalogAsync(catalog, args, catalogStarted);
// 4. Composition des services (un ensemble de services par jeu)
Views.MainWindow window;
try
@@ -330,7 +344,8 @@ public partial class App : Application, ILauncherRestartHost
if (!readOnly)
{
var (apiUrl, apiUrlError) = ResolveAdminApiUrl(catalog);
_admin = new AdminWindowService(window, CreateModules(), catalog, apiUrl, apiUrlError, version);
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
ThemeManager.BuiltInThemes.ToList());
window.AdminRequested += _admin.Open;
}
@@ -340,10 +355,158 @@ public partial class App : Application, ILauncherRestartHost
return window;
}
// ------------------------------------------------------------------ Catalogue des jeux
/// <summary>Attente maximale du catalogue à jour au démarrage (au-delà : cache, mise à jour au démarrage suivant).</summary>
private static readonly TimeSpan CatalogStartupBudget = TimeSpan.FromSeconds(2.5);
private static readonly TimeSpan CatalogImagesBudget = TimeSpan.FromSeconds(1.5);
/// <summary>Applique le catalogue en cache et lance le téléchargement de la version à jour.</summary>
private void StartCatalog(LauncherCatalog catalog, CommandLine args, bool download)
{
var url = ResolveCatalogUrl(catalog);
if (url is null || _http is null) return;
try
{
_catalogService = new CatalogService(_http, catalog.DataDirectory, new Uri(url));
if (_catalogService.LoadCached() is { } cached) ApplyCatalog(catalog, cached, args);
if (download) _catalogFetch = _catalogService.FetchAsync();
}
catch (Exception ex)
{
Log.Error("Catalogue des jeux", ex);
}
}
/// <summary>
/// Attend brièvement le catalogue à jour (et ses nouvelles images) puis l'applique. Trop lent :
/// le cache reste appliqué, et le téléchargement se termine en arrière-plan pour le démarrage suivant.
/// </summary>
private async Task ApplyFreshCatalogAsync(LauncherCatalog catalog, CommandLine args, DateTime started)
{
if (_catalogFetch is not { } fetch || _catalogService is not { } service) return;
try
{
var remaining = CatalogStartupBudget - (DateTime.UtcNow - started);
if (remaining > TimeSpan.Zero) await Task.WhenAny(fetch, Task.Delay(remaining));
if (!fetch.IsCompleted)
{
Log.Info("Catalogue des jeux : serveur lent, catalogue en cache utilisé (mise à jour au prochain démarrage).");
_ = FinishCatalogInBackgroundAsync(fetch, service);
return;
}
if (await fetch is not { } fresh) return;
var missing = fresh.Games.Values.SelectMany(g => new[] { g.Icon, g.Background })
.Where(u => u is not null && service.CachedImage(u) is null).Distinct().ToList();
if (missing.Count > 0)
await Task.WhenAny(Task.WhenAll(missing.Select(u => service.GetImageAsync(u))), Task.Delay(CatalogImagesBudget));
ApplyCatalog(catalog, fresh, args);
Log.Info($"Catalogue des jeux : révision {fresh.Revision} appliquée.");
_ = FinishCatalogInBackgroundAsync(fetch, service);
}
catch (Exception ex)
{
Log.Error("Catalogue des jeux", ex);
}
}
/// <summary>Fin du téléchargement en arrière-plan : images manquantes, nettoyage du cache d'images.</summary>
private static async Task FinishCatalogInBackgroundAsync(Task<CatalogDocument?> fetch, CatalogService service)
{
try
{
if (await fetch is not { } fresh) return;
foreach (var url in fresh.Games.Values.SelectMany(g => new[] { g.Icon, g.Background }).Where(u => u is not null).Distinct())
await service.GetImageAsync(url);
service.PruneImages(fresh);
}
catch (Exception ex)
{
Log.Warn($"Catalogue des jeux (arrière-plan) : {ex.Message}");
}
}
private void ApplyCatalog(LauncherCatalog catalog, CatalogDocument document, CommandLine args)
{
var requested = new[] { args.GameId, args.UninstallGameId };
CatalogMerger.Apply(catalog, document, ThemeManager.BuiltInThemes.ToList(),
url => _catalogService?.CachedImage(url),
game => requested.Any(id => string.Equals(id, game.GameId, StringComparison.OrdinalIgnoreCase)) || IsInstalled(catalog, game));
}
/// <summary>Jeu installé sur ce PC (registre) : jamais masqué par le catalogue.</summary>
private bool IsInstalled(LauncherCatalog catalog, LauncherConfig game)
{
try
{
return new RegistryService(game, _launcherPath, catalog.DataFolderName, ownsLauncherInstance: false).Read() is not null;
}
catch (Exception ex)
{
Log.Warn($"Lecture du registre ({game.GameId}) : {ex.Message}");
return true; // dans le doute, le jeu reste affiché
}
}
/// <summary>
/// Adresse du catalogue (« catalogUrl »). Comme apiUrl : en Release, seulement celle du
/// launcher.json intégré à l'exécutable ; en Debug, celle de la configuration utilisée.
/// </summary>
private static string? ResolveCatalogUrl(LauncherCatalog catalog)
{
#if DEBUG
if (catalog.CatalogUrlError is not null) Log.Warn(catalog.CatalogUrlError);
return catalog.CatalogUrl;
#else
try
{
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName);
var integrated = embedded is null ? null : LauncherCatalog.Load(embedded);
if (catalog.CatalogUrl is not null && !string.Equals(catalog.CatalogUrl, integrated?.CatalogUrl, StringComparison.Ordinal))
Log.Warn("« catalogUrl » du launcher.json externe ignorée : seule celle intégrée à l'exécutable est utilisée.");
if (integrated?.CatalogUrlError is { } error) Log.Warn(error);
return integrated?.CatalogUrl;
}
catch (Exception ex)
{
Log.Error("Adresse du catalogue (launcher.json intégré)", ex);
return null;
}
#endif
}
/// <summary>
/// Jeux tels que déclarés dans launcher.json (valeurs par défaut, avant le catalogue), pour le menu
/// d'administration. En Release : le launcher.json INTÉGRÉ à l'exe (un fichier posé à côté ne décide
/// pas des jeux que l'administration modifie) ; en Debug : la configuration utilisée.
/// </summary>
private LauncherCatalog LoadDeclaredCatalog(LauncherCatalog active)
{
try
{
#if DEBUG
return LoadCatalog(_args?.ConfigPath);
#else
using var embedded = Assembly.GetExecutingAssembly().GetManifestResourceStream(ConfigFileName)
?? throw new FileNotFoundException($"{ConfigFileName} intégré introuvable.");
return LauncherCatalog.Load(embedded);
#endif
}
catch (Exception ex)
{
Log.Error("launcher.json (jeux déclarés, menu d'administration)", ex);
return active;
}
}
/// <summary>Modules du launcher (pages du menu d'administration…). Les futurs modules s'ajoutent ici.</summary>
private static ModuleCatalog CreateModules() => new(new ILauncherModule[]
{
new AdminModule()
new AdminModule(),
new CatalogAdminModule(),
new ChangelogAdminModule()
});
/// <summary>
@@ -481,7 +644,9 @@ public partial class App : Application, ILauncherRestartHost
/// <returns>Vrai si le jeu fait partie de ce launcher.</returns>
private bool OnLaunchRequestedByAnotherInstance(string gameId)
{
var known = _catalog?.Find(gameId) is not null;
// Jeux AFFICHÉS uniquement (un jeu masqué par le catalogue n'est pas proposé) : sinon la demande
// serait acceptée sans que le launcher puisse l'afficher.
var known = _catalog?.Games.Any(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase)) == true;
Dispatcher.InvokeAsync(() => HandleLaunchRequest(gameId));
return known;
}
@@ -20,7 +20,8 @@ public sealed class AdminWindowService : IDisposable
{
private readonly Window _owner;
private readonly ModuleCatalog _modules;
private readonly IReadOnlyList<LauncherConfig> _games;
private readonly LauncherCatalog _declared;
private readonly IReadOnlyList<string> _themes;
private readonly string _settingsPath;
private readonly string _launcherVersion;
private readonly Uri? _apiUrl;
@@ -30,12 +31,16 @@ public sealed class AdminWindowService : IDisposable
/// <param name="apiUrl">Adresse de l'API (null : menu indisponible).</param>
/// <param name="apiUrlError">Si l'adresse configurée a été ignorée (invalide), la raison, affichée à l'ouverture.</param>
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, string? apiUrl, string? apiUrlError,
string launcherVersion)
/// <param name="catalog">Configuration active (dossier des données du launcher).</param>
/// <param name="declared">Jeux déclarés dans launcher.json, avant le catalogue (valeurs par défaut de la page « Jeux »).</param>
/// <param name="themes">Thèmes intégrés au launcher (seuls choix possibles dans le catalogue).</param>
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes)
{
_owner = owner;
_modules = modules;
_games = catalog.Games;
_declared = declared;
_themes = themes;
_settingsPath = Path.Combine(catalog.DataDirectory, "admin.json");
_launcherVersion = launcherVersion;
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
@@ -71,7 +76,10 @@ public sealed class AdminWindowService : IDisposable
{
Client = _client,
Modules = _modules,
Games = _games,
Games = _declared.DeclaredGames,
LauncherName = _declared.DeclaredLauncherName,
DefaultGameId = _declared.DeclaredDefaultGameId,
Themes = _themes,
Dialogs = new ModuleDialogs(() => window),
Ui = new WpfUiDispatcher(_owner.Dispatcher),
Tpm = new TpmKeyStore(), // clés dans la puce de sécurité : proposées après vérification réelle de la puce
+26 -3
View File
@@ -61,8 +61,16 @@ public static class ThemeManager
return dictionary.Contains(key) ? dictionary[key] : null;
}
/// <summary>
/// Plus grande dimension (pixels) d'une image de fond une fois chargée : une image plus grande
/// (fond du catalogue, par exemple) est réduite au chargement pour borner la mémoire utilisée.
/// </summary>
public const int MaxBackgroundSize = 3840;
/// <summary>Charge une image depuis un chemin (absolu ou relatif au launcher). Null si absente/invalide.</summary>
public static ImageSource? LoadImage(string? path, int decodeWidth = 0)
/// <param name="decodeWidth">Largeur imposée au chargement (0 : taille de l'image).</param>
/// <param name="maxSize">Sans largeur imposée : plus grande dimension autorisée (0 : aucune limite).</param>
public static ImageSource? LoadImage(string? path, int decodeWidth = 0, int maxSize = 0)
{
if (string.IsNullOrWhiteSpace(path)) return null;
try
@@ -78,7 +86,22 @@ public static class ThemeManager
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad; // ne garde pas le fichier ouvert
image.UriSource = new Uri(full, UriKind.Absolute);
if (decodeWidth > 0) image.DecodePixelWidth = decodeWidth;
if (decodeWidth > 0)
{
image.DecodePixelWidth = decodeWidth;
}
else if (maxSize > 0)
{
// Dimensions lues dans l'en-tête, sans décoder l'image : réduction proportionnelle si besoin.
int width, height;
using (var stream = File.OpenRead(full))
{
var frame = BitmapDecoder.Create(stream, BitmapCreateOptions.DelayCreation, BitmapCacheOption.None).Frames[0];
(width, height) = (frame.PixelWidth, frame.PixelHeight);
}
if (width >= height && width > maxSize) image.DecodePixelWidth = maxSize;
else if (height > width && height > maxSize) image.DecodePixelHeight = maxSize;
}
image.EndInit();
image.Freeze();
return image;
@@ -108,7 +131,7 @@ public static class ThemeManager
if (specific is not null) result.MergedDictionaries.Add(specific);
}
var background = LoadImage(backgroundOverride);
var background = LoadImage(backgroundOverride, maxSize: MaxBackgroundSize);
if (background is not null) result["BackgroundImage"] = background;
Cache[cacheKey] = result;
+583 -1
View File
@@ -1,7 +1,9 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
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:avm="clr-namespace:GameLauncher.Modules.Admin.ViewModels;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">
<!--
Menu d'administration (Views/Admin) : styles communs et modèles des pages des modules.
@@ -428,4 +430,584 @@
</DataTemplate.Triggers>
</DataTemplate>
<!-- ============================================================ Page « Jeux » (catalogue) -->
<!-- Élément de la liste « Launcher / jeux » : icône, titre, sous-titre, pastille « modifié ». -->
<Style x:Key="AdminCatalogItemStyle" 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="14" Padding="12,9" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="32" Height="32" CornerRadius="10" Background="{DynamicResource BadgeBrush}">
<Path x:Name="Icon" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource TextSecondaryBrush}"
Data="{Binding IconKey, Converter={StaticResource IconKey}}" />
</Border>
<StackPanel Grid.Column="1" Margin="11,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Subtitle}" FontSize="11.5" Margin="0,1,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
<!-- Modifié depuis la dernière publication -->
<Ellipse x:Name="Dirty" Grid.Column="2" Width="8" Height="8" Margin="8,0,0,0" VerticalAlignment="Center"
Fill="{DynamicResource WarningBrush}" Visibility="Collapsed" ToolTip="Modifié, non publié" />
</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="Icon" Property="Fill" 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>
<DataTemplate DataType="{x:Type acat:GamesPanelViewModel}">
<StackPanel>
<!-- Erreur (chargement, publication refusée par le 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}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
</Border>
<!-- Publication non configurée sur le serveur -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding ShowPublishingDisabled, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620"
Text="Publication désactivée : le fichier public du catalogue n'est pas configuré sur le serveur (LauncherApi:Catalog:PublicFile). Les modifications peuvent être préparées mais pas publiées." />
</StackPanel>
</Border>
<!-- Publié entre-temps par un autre administrateur -->
<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 catalogue a été publié par quelqu'un d'autre pendant vos modifications : rechargez-le avant de publier." />
</DockPanel>
</Border>
<!-- Résultat de la dernière publication -->
<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>
<!-- Saisie suspendue pendant un chargement ou une publication -->
<Grid IsEnabled="{Binding IsEditable}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="240" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Launcher, puis les jeux dans l'ordre d'affichage -->
<StackPanel>
<ListBox ItemsSource="{Binding Items}" SelectedItem="{Binding Selected, Mode=TwoWay}"
ItemContainerStyle="{StaticResource AdminCatalogItemStyle}"
Background="Transparent" BorderThickness="0" Padding="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Disabled" />
<DockPanel Margin="2,4,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" ToolTip="Monter le jeu sélectionné"
Command="{Binding MoveUpCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.ArrowDown}" ToolTip="Descendre le jeu sélectionné"
Command="{Binding MoveDownCommand}" />
</StackPanel>
<TextBlock Text="Ordre d'affichage" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
</StackPanel>
<!-- Élément sélectionné (modèles LauncherEditor / GameEditor ci-dessous) -->
<ContentControl Grid.Column="1" Margin="18,0,0,0" Content="{Binding Selected}" Focusable="False" />
</Grid>
<!-- Publication -->
<Border Height="1" Background="{DynamicResource BorderBrush}" Margin="0,18,0,14" />
<DockPanel Margin="0,0,0,8">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}"
ToolTip="Afficher le catalogue actuellement publié" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Publier"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding PublishCommand}"
ToolTip="Contrôle les valeurs puis publie le catalogue (requête signée par votre clé)" />
</StackPanel>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding RevisionText}" Style="{StaticResource AdminHintStyle}" TextWrapping="Wrap" />
<TextBlock Text="{Binding PublicUrl}" Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0"
TextTrimming="CharacterEllipsis" ToolTip="Adresse publique lue par les launchers" />
</StackPanel>
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<!-- Réglages du launcher -->
<DataTemplate DataType="{x:Type acat:LauncherEditor}">
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<TextBlock Text="Launcher" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,14" TextWrapping="Wrap"
Text="Champ vide : valeur de launcher.json. L'ordre des jeux se règle avec les flèches sous la liste." />
<TextBlock Text="Nom du launcher" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding LauncherName, UpdateSourceTrigger=PropertyChanged}" MaxLength="80" />
<TextBlock Text="{Binding DefaultNameHint}" Style="{StaticResource AdminHintStyle}" Margin="2,4,0,14" TextWrapping="Wrap" />
<TextBlock Text="Jeu affiché au premier lancement" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding GameChoices}"
SelectedItem="{Binding DefaultGame, Mode=TwoWay}" DisplayMemberPath="Label" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,4,0,0" TextWrapping="Wrap"
Text="Ensuite, le launcher rouvre le dernier jeu choisi par le joueur." />
</StackPanel>
</Border>
</DataTemplate>
<!-- Un lien d'un jeu (Discord, YouTube…) -->
<DataTemplate DataType="{x:Type acat:LinkEditor}">
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="130" />
<ColumnDefinition Width="130" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Kinds}"
SelectedItem="{Binding Kind, Mode=TwoWay}" DisplayMemberPath="Label" ToolTip="Type de lien (icône affichée)" />
<TextBox Grid.Column="1" Margin="8,0,0,0" Style="{StaticResource ModernTextBoxStyle}" MaxLength="40"
Text="{Binding Label, UpdateSourceTrigger=PropertyChanged}" ToolTip="Texte du bouton (vide : nom du type)" />
<TextBox Grid.Column="2" Margin="8,0,0,0" Style="{StaticResource ModernTextBoxStyle}" MaxLength="500"
Text="{Binding Url, UpdateSourceTrigger=PropertyChanged}" ToolTip="Adresse https://…" />
<Button Grid.Column="3" Margin="6,0,0,0" Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" ToolTip="Retirer ce lien" Command="{Binding RemoveCommand}" />
</Grid>
</DataTemplate>
<!-- Informations modifiables d'un jeu -->
<DataTemplate DataType="{x:Type acat:GameEditor}">
<StackPanel>
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<TextBlock Text="Présentation" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,14" TextWrapping="Wrap">
<Run Text="Jeu" />
<Run Text="{Binding GameId, Mode=OneWay}" FontWeight="SemiBold" />
<Run Text="· champ vide : valeur de launcher.json." />
</TextBlock>
<TextBlock Text="Nom affiché" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding GameName, UpdateSourceTrigger=PropertyChanged}" MaxLength="80" />
<TextBlock Text="{Binding NameHint}" Style="{StaticResource AdminHintStyle}" Margin="2,4,0,14" TextWrapping="Wrap" />
<TextBlock Text="Accroche" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding Tagline, UpdateSourceTrigger=PropertyChanged}" MaxLength="200" />
<TextBlock Text="{Binding TaglineHint}" Style="{StaticResource AdminHintStyle}" Margin="2,4,0,14" TextWrapping="Wrap" />
<TextBlock Text="Description" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" Text="{Binding Description, UpdateSourceTrigger=PropertyChanged}" MaxLength="2000" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="2,4,0,14" TextWrapping="Wrap"
Text="Texte simple (2000 caractères au plus), affiché sous l'accroche." />
<TextBlock Text="Site web" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding WebsiteUrl, UpdateSourceTrigger=PropertyChanged}" MaxLength="500" />
<TextBlock Text="{Binding WebsiteHint}" Style="{StaticResource AdminHintStyle}" Margin="2,4,0,0" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<DockPanel Margin="0,0,0,12">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Ajouter un lien"
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding AddLinkCommand}" />
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Liens" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="Boutons affichés sous la description (8 au plus, adresses https)." Style="{StaticResource AdminHintStyle}"
Margin="0,3,0,0" TextWrapping="Wrap" />
</StackPanel>
</DockPanel>
<ItemsControl ItemsSource="{Binding Links}" Focusable="False" />
</StackPanel>
</Border>
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<TextBlock Text="Apparence et comportement" Style="{StaticResource AdminCardTitleStyle}" Margin="0,0,0,14" />
<TextBlock Text="Thème" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding ThemeChoices}"
SelectedItem="{Binding Theme, Mode=TwoWay}" DisplayMemberPath="Label" />
<TextBlock Text="Thèmes intégrés au launcher uniquement." Style="{StaticResource AdminHintStyle}" Margin="2,4,0,14" />
<TextBlock Text="Icône" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding Icon, UpdateSourceTrigger=PropertyChanged}" MaxLength="500" />
<TextBlock Text="Image de fond" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,12,0,0" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Text="{Binding Background, UpdateSourceTrigger=PropertyChanged}" MaxLength="500" />
<TextBlock Text="{Binding ImageHint}" Style="{StaticResource AdminHintStyle}" Margin="2,4,0,14" TextWrapping="Wrap" />
<TextBlock Text="Recherche de mise à jour (minutes)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Width="140" HorizontalAlignment="Left" MaxLength="5"
Text="{Binding UpdateInterval, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="{Binding IntervalHint}" Style="{StaticResource AdminHintStyle}" Margin="2,4,0,16" TextWrapping="Wrap" />
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding Hidden, Mode=TwoWay}" FontSize="13">
<StackPanel>
<TextBlock Text="Masquer ce jeu" FontWeight="SemiBold" />
<TextBlock Style="{StaticResource AdminHintStyle}" TextWrapping="Wrap" Margin="0,2,0,0"
Text="Retiré de la liste des joueurs qui ne l'ont pas installé (un jeu installé reste affiché)." />
</StackPanel>
</CheckBox>
</StackPanel>
</Border>
</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>
</ResourceDictionary>
+128
View File
@@ -459,6 +459,134 @@
</Setter>
</Style>
<!-- Champ texte sur plusieurs lignes (description) : même apparence que ModernTextBoxStyle -->
<Style x:Key="ModernMultilineTextBoxStyle" TargetType="TextBox" BasedOn="{StaticResource ModernTextBoxStyle}">
<Setter Property="Height" Value="Auto" />
<Setter Property="MinHeight" Value="96" />
<Setter Property="MaxHeight" Value="220" />
<Setter Property="Padding" Value="12,10" />
<Setter Property="AcceptsReturn" Value="True" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="VerticalContentAlignment" Value="Top" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="Bd" CornerRadius="12" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}" Focusable="False"
HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ============================================================ Liste déroulante arrondie -->
<Style x:Key="ModernComboBoxItemStyle" TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd" CornerRadius="9" Padding="10,8" Margin="0,1" Background="Transparent">
<ContentPresenter VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
<Setter Property="Foreground" Value="{DynamicResource Accent2Brush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ModernComboBoxToggleStyle" TargetType="ToggleButton">
<Setter Property="Focusable" Value="False" />
<Setter Property="ClickMode" Value="Press" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Bd" CornerRadius="12" Background="{DynamicResource InputBrush}"
BorderBrush="{DynamicResource BorderStrongBrush}" BorderThickness="1">
<Path x:Name="Arrow" Width="10" Height="10" Margin="0,0,14,0" Stretch="Uniform"
HorizontalAlignment="Right" VerticalAlignment="Center"
Fill="{DynamicResource TextSecondaryBrush}" Data="M 0 0 L 5 5 L 10 0 Z" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="#55FFFFFF" />
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="Arrow" Property="Fill" Value="{DynamicResource Accent2Brush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="ModernComboBoxStyle" TargetType="ComboBox">
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Height" Value="40" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="ItemContainerStyle" Value="{StaticResource ModernComboBoxItemStyle}" />
<Setter Property="MaxDropDownHeight" Value="280" />
<Setter Property="ScrollViewer.CanContentScroll" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="Toggle" Style="{StaticResource ModernComboBoxToggleStyle}"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" />
<ContentPresenter Margin="12,0,34,0" VerticalAlignment="Center" HorizontalAlignment="Left" IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}" />
<Popup x:Name="PART_Popup" Placement="Bottom" AllowsTransparency="True" Focusable="False"
PopupAnimation="Fade" VerticalOffset="4"
IsOpen="{Binding IsDropDownOpen, RelativeSource={RelativeSource TemplatedParent}}">
<Border Margin="0,0,8,10" Padding="5" CornerRadius="12" SnapsToDevicePixels="True"
MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}"
MaxHeight="{TemplateBinding MaxDropDownHeight}"
Background="{DynamicResource MenuBrush}" BorderBrush="{DynamicResource BorderStrongBrush}" BorderThickness="1">
<Border.Effect>
<DropShadowEffect BlurRadius="18" ShadowDepth="4" Direction="270" Opacity="0.5" Color="Black" />
</Border.Effect>
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained" />
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Champ de phrase de passe : même apparence que ModernTextBoxStyle (administration) -->
<Style x:Key="ModernPasswordBoxStyle" TargetType="PasswordBox">
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
+15
View File
@@ -71,4 +71,19 @@
<Geometry x:Key="Icon.FileLockOutline">M18 13C16.6 13 15.2 14.1 15.2 15.5V17C14.6 17 14 17.6 14 18.2V21.7C14 22.4 14.6 23 15.2 23H20.7C21.4 23 22 22.4 22 21.8V18.3C22 17.6 21.4 17 20.8 17V15.5C20.8 14.1 19.4 13 18 13M18 14.2C18.8 14.2 19.5 14.7 19.5 15.5V17H16.5V15.5C16.5 14.7 17.2 14.2 18 14.2M6 2C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H12V20H6V4H13V9H18V11C18.7 11 19.4 11.2 20 11.4V8L14 2H6Z</Geometry>
<Geometry x:Key="Icon.BadgeAccountHorizontal">M22,4H14V7H10V4H2A2,2 0 0,0 0,6V20A2,2 0 0,0 2,22H22A2,2 0 0,0 24,20V6A2,2 0 0,0 22,4M8,9A2,2 0 0,1 10,11A2,2 0 0,1 8,13A2,2 0 0,1 6,11A2,2 0 0,1 8,9M12,17H4V16C4,14.67 6.67,14 8,14C9.33,14 12,14.67 12,16V17M20,18H14V16H20V18M20,14H14V12H20V14M20,10H14V8H20V10M13,6H11V2H13V6Z</Geometry>
<!-- Catalogue des jeux (liens, page « Jeux ») : icônes génériques, pas de logos de marques -->
<Geometry x:Key="Icon.Forum">M17,12V3A1,1 0 0,0 16,2H3A1,1 0 0,0 2,3V17L6,13H16A1,1 0 0,0 17,12M21,6H19V15H6V17A1,1 0 0,0 7,18H18L22,22V7A1,1 0 0,0 21,6Z</Geometry>
<Geometry x:Key="Icon.PlayCircleOutline">M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z</Geometry>
<Geometry x:Key="Icon.At">M12,15C12.81,15 13.5,14.7 14.11,14.11C14.7,13.5 15,12.81 15,12C15,11.19 14.7,10.5 14.11,9.89C13.5,9.3 12.81,9 12,9C11.19,9 10.5,9.3 9.89,9.89C9.3,10.5 9,11.19 9,12C9,12.81 9.3,13.5 9.89,14.11C10.5,14.7 11.19,15 12,15M12,2C14.75,2 17.1,3 19.05,4.95C21,6.9 22,9.25 22,12V13.45C22,14.45 21.65,15.3 21,16C20.3,16.67 19.5,17 18.5,17C17.3,17 16.31,16.5 15.56,15.5C14.56,16.5 13.38,17 12,17C10.63,17 9.45,16.5 8.46,15.54C7.5,14.55 7,13.38 7,12C7,10.63 7.5,9.45 8.46,8.46C9.45,7.5 10.63,7 12,7C13.38,7 14.55,7.5 15.54,8.46C16.5,9.45 17,10.63 17,12V13.45C17,13.86 17.16,14.22 17.46,14.53C17.76,14.84 18.11,15 18.5,15C18.92,15 19.27,14.84 19.57,14.53C19.87,14.22 20,13.86 20,13.45V12C20,9.81 19.23,7.93 17.65,6.35C16.07,4.77 14.19,4 12,4C9.81,4 7.93,4.77 6.35,6.35C4.77,7.93 4,9.81 4,12C4,14.19 4.77,16.07 6.35,17.65C7.93,19.23 9.81,20 12,20H17V22H12C9.25,22 6.9,21 4.95,19.05C3,17.1 2,14.75 2,12C2,9.25 3,6.9 4.95,4.95C6.9,3 9.25,2 12,2Z</Geometry>
<Geometry x:Key="Icon.Broadcast">M12 10C10.9 10 10 10.9 10 12S10.9 14 12 14 14 13.1 14 12 13.1 10 12 10M18 12C18 8.7 15.3 6 12 6S6 8.7 6 12C6 14.2 7.2 16.1 9 17.2L10 15.5C8.8 14.8 8 13.5 8 12.1C8 9.9 9.8 8.1 12 8.1S16 9.9 16 12.1C16 13.6 15.2 14.9 14 15.5L15 17.2C16.8 16.2 18 14.2 18 12M12 2C6.5 2 2 6.5 2 12C2 15.7 4 18.9 7 20.6L8 18.9C5.6 17.5 4 14.9 4 12C4 7.6 7.6 4 12 4S20 7.6 20 12C20 15 18.4 17.5 16 18.9L17 20.6C20 18.9 22 15.7 22 12C22 6.5 17.5 2 12 2Z</Geometry>
<Geometry x:Key="Icon.LinkVariant">M10.59,13.41C11,13.8 11,14.44 10.59,14.83C10.2,15.22 9.56,15.22 9.17,14.83C7.22,12.88 7.22,9.71 9.17,7.76V7.76L12.71,4.22C14.66,2.27 17.83,2.27 19.78,4.22C21.73,6.17 21.73,9.34 19.78,11.29L18.29,12.78C18.3,11.96 18.17,11.14 17.89,10.36L18.36,9.88C19.54,8.71 19.54,6.81 18.36,5.64C17.19,4.46 15.29,4.46 14.12,5.64L10.59,9.17C9.41,10.34 9.41,12.24 10.59,13.41M13.41,9.17C13.8,8.78 14.44,8.78 14.83,9.17C16.78,11.12 16.78,14.29 14.83,16.24V16.24L11.29,19.78C9.34,21.73 6.17,21.73 4.22,19.78C2.27,17.83 2.27,14.66 4.22,12.71L5.71,11.22C5.7,12.04 5.83,12.86 6.11,13.65L5.64,14.12C4.46,15.29 4.46,17.19 5.64,18.36C6.81,19.54 8.71,19.54 9.88,18.36L13.41,14.83C14.59,13.66 14.59,11.76 13.41,10.59C13,10.2 13,9.56 13.41,9.17Z</Geometry>
<Geometry x:Key="Icon.OpenInNew">M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z</Geometry>
<Geometry x:Key="Icon.ImageOutline">M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z</Geometry>
<Geometry x:Key="Icon.EyeOffOutline">M2,5.27L3.28,4L20,20.72L18.73,22L15.65,18.92C14.5,19.3 13.28,19.5 12,19.5C7,19.5 2.73,16.39 1,12C1.69,10.24 2.79,8.69 4.19,7.46L2,5.27M12,9A3,3 0 0,1 15,12C15,12.35 14.94,12.69 14.83,13L11,9.17C11.31,9.06 11.65,9 12,9M12,4.5C17,4.5 21.27,7.61 23,12C22.18,14.08 20.79,15.88 19,17.19L17.58,15.76C18.94,14.82 20.06,13.54 20.82,12C19.17,8.64 15.76,6.5 12,6.5C10.91,6.5 9.84,6.68 8.84,7L7.3,5.47C8.74,4.85 10.33,4.5 12,4.5M3.18,12C4.83,15.36 8.24,17.5 12,17.5C12.69,17.5 13.37,17.43 14,17.29L11.72,15C10.29,14.85 9.15,13.71 9,12.28L5.6,8.87C4.61,9.72 3.78,10.78 3.18,12Z</Geometry>
<Geometry x:Key="Icon.ArrowUp">M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z</Geometry>
<Geometry x:Key="Icon.ArrowDown">M11,4H13V16L18.5,10.5L19.92,11.92L12,19.84L4.08,11.92L5.5,10.5L11,16V4Z</Geometry>
<Geometry x:Key="Icon.TextBoxOutline">M5,3C3.89,3 3,3.89 3,5V19C3,20.11 3.89,21 5,21H19C20.11,21 21,20.11 21,19V5C21,3.89 20.11,3 19,3H5M5,5H19V19H5V5M7,7V9H17V7H7M7,11V13H17V11H7M7,15V17H14V15H7Z</Geometry>
<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>
</ResourceDictionary>
+31 -2
View File
@@ -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;
@@ -111,7 +112,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
public LauncherConfig Config => _config;
public string GameId => _config.GameId;
public string GameName => _config.GameName;
/// <summary>Nom affiché (catalogue, sinon launcher.json). Le raccourci et le registre gardent celui de launcher.json.</summary>
public string GameName => _config.ShownName;
public string ThemeName => string.IsNullOrWhiteSpace(_config.Theme) ? "default" : _config.Theme;
/// <summary>Initiales affichées dans la vignette quand le jeu n'a pas d'icône ("Exovival Zombie Reborn" → "EZR").</summary>
@@ -149,6 +151,17 @@ public sealed class GameViewModel : ObservableObject, IDisposable
}
public string Publisher => _config.Publisher;
public string Tagline => _config.Tagline;
/// <summary>Présentation du jeu (catalogue).</summary>
public string? Description => _config.Description;
public bool HasDescription => !string.IsNullOrWhiteSpace(_config.Description);
/// <summary>Liens du catalogue (Discord, YouTube…), adresses https contrôlées.</summary>
public IReadOnlyList<GameLinkItem> Links => _links ??= _config.Links
.Select(l => new GameLinkItem(l.Label, l.Url, GameLinkItem.IconFor(l.Kind), new RelayCommand(() => _window.OpenUrl(l.Url))))
.ToList();
private IReadOnlyList<GameLinkItem>? _links;
public bool HasLinks => _config.Links.Count > 0;
public string LauncherVersion { get; }
public bool HasWebsite => !string.IsNullOrWhiteSpace(_config.WebsiteUrl);
public bool IsExitConfirmed { get; private set; }
@@ -692,7 +705,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
return;
}
var options = _dialogs.AskInstallOptions(GameName,
// Nom de launcher.json (jamais celui du catalogue) : il sert aussi au sous-dossier d'installation.
var options = _dialogs.AskInstallOptions(_config.GameName,
_lastInstallDirectory ?? _config.ExpandedDefaultInstallDirectory,
manifest.Archive.Size, manifest.Archive.Size + manifest.TotalSize);
if (options is null) return;
@@ -1502,3 +1516,18 @@ public sealed class GameViewModel : ObservableObject, IDisposable
_lifetime.Dispose();
}
}
/// <summary>Lien affiché sous la présentation du jeu (icône intégrée générique, jamais une image distante).</summary>
public sealed record GameLinkItem(string Label, string Url, string IconKey, System.Windows.Input.ICommand OpenCommand)
{
public static string IconFor(string kind) => kind switch
{
"website" => "Web",
"discord" => "Forum",
"youtube" => "PlayCircleOutline",
"x" => "At",
"twitch" => "Broadcast",
"steam" => "Gamepad",
_ => "LinkVariant"
};
}
@@ -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;
+23
View File
@@ -297,6 +297,29 @@
Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding Tagline, Converter={StaticResource StringToVisibility}}" />
<!-- Présentation et liens (catalogue des jeux, modifiables depuis le menu d'administration) -->
<ScrollViewer Margin="0,12,0,0" MaxWidth="540" MaxHeight="96" HorizontalAlignment="Left"
VerticalScrollBarVisibility="Auto" Focusable="False"
Visibility="{Binding HasDescription, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding Description}" FontSize="13" LineHeight="20" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</ScrollViewer>
<ItemsControl ItemsSource="{Binding Links}" Margin="0,14,0,0" Focusable="False"
Visibility="{Binding HasLinks, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel MaxWidth="540" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Style="{StaticResource SmallButtonStyle}" Margin="0,0,8,8" Content="{Binding Label}"
ToolTip="{Binding Url}" Command="{Binding OpenCommand}"
ui:IconAssist.Icon="{Binding IconKey, Converter={StaticResource IconKey}}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Informations sur l'installation -->
<WrapPanel Margin="0,28,0,0">
<Border Style="{StaticResource ChipStyle}"
+6
View File
@@ -31,6 +31,12 @@
// pour retirer le menu d'administration.
"apiUrl": "https://launcher.lanisys.fr/api/",
// Catalogue des jeux : informations modifiables depuis le menu d'administration (nom affiché,
// accroche, présentation, liens, thème intégré, images, ordre…), lues au démarrage puis mises en
// cache. Comme apiUrl, la version Release ne la lit que dans le launcher.json intégré à l'exe.
// Supprimez la ligne pour n'utiliser que les valeurs de ce fichier.
"catalogUrl": "https://launcher.lanisys.fr/catalog/catalog.json",
"websiteUrl": "https://lanisys.fr",
// Thème du launcher pour ce jeu : "default" (violet/cyan) ou "exovival" (couloir sombre, vert).
@@ -28,6 +28,12 @@
// uniquement (un seul serveur pour tous les jeux). Supprimez la ligne pour retirer le menu d'administration.
"apiUrl": "https://launcher.lanisys.fr/api/",
// Catalogue des jeux : informations modifiables depuis le menu d'administration (nom affiché,
// accroche, présentation, liens, thème intégré, images, ordre…), lues au démarrage puis mises en
// cache. Comme apiUrl, la version Release ne la lit que dans le launcher.json intégré à l'exe.
// Supprimez la ligne pour n'utiliser que les valeurs de ce fichier.
"catalogUrl": "https://launcher.lanisys.fr/catalog/catalog.json",
// Jeu affiché au premier lancement (ensuite : le dernier jeu sélectionné).
"defaultGame": "EZR",
@@ -83,12 +83,14 @@ public static class AdminTestKit
};
public static AdminEnvironment Environment(TestApi api, string folder, FakeDialogs dialogs, ModuleCatalog? modules = null,
IAdminTpm? tpm = null) => new()
IAdminTpm? tpm = null, IReadOnlyList<LauncherConfig>? games = null,
IReadOnlyList<string>? themes = null) => new()
{
Tpm = tpm,
Client = api.CreateClient(),
Modules = modules ?? new ModuleCatalog(new ILauncherModule[] { new AdminModule() }),
Games = new[] { Game() },
Games = games ?? new[] { Game() },
Themes = themes ?? new[] { "default" },
Dialogs = dialogs,
Ui = ImmediateDispatcher.Instance,
SettingsPath = System.IO.Path.Combine(folder, "admin.json"),
@@ -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,198 @@
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Jeux » du menu d'administration (catalogue publié pour les launchers des joueurs).</summary>
public class GamesPanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new CatalogAdminModule() });
private static string PublicFile()
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-catalog-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return Path.Combine(directory, "catalog.json");
}
private static async Task<GamesPanelViewModel> OpenGamesPageAsync(AdminShellViewModel shell)
{
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(GamesPanelViewModel.PanelId);
await workspace.Activation;
Assert.Equal(GamesPanelViewModel.PanelId, workspace.SelectedPanel!.Descriptor.Id);
return Assert.IsType<GamesPanelViewModel>(workspace.SelectedPanel.ViewModel);
}
[Fact]
public async Task Games_page_edits_checks_and_publishes_the_catalog()
{
var publicFile = PublicFile();
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
var games = new[] { AdminTestKit.Game(), AdminTestKit.Game("DEMO", "Démo") };
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules,
games: games, themes: new[] { "default", "neon" }));
var page = await OpenGamesPageAsync(shell);
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.PublishingEnabled);
Assert.False(page.ShowPublishingDisabled);
Assert.StartsWith("Jamais publié", page.RevisionText);
Assert.Equal(new[] { "Launcher", "Exovival Zombie Reborn", "Démo" }, page.Items.Select(i => i.Title));
Assert.Same(page.Launcher, page.Selected);
Assert.False(page.HasChanges);
Assert.False(page.PublishCommand.CanExecute(null));
Assert.False(page.MoveUpCommand.CanExecute(null)); // « Launcher » ne se déplace pas
// Modifications : accroche, lien, thème, image ; ordre des jeux.
var ezr = page.Games.Single(g => g.GameId == "EZR");
var demo = page.Games.Single(g => g.GameId == "DEMO");
ezr.Tagline = "Survivez à l'école.";
ezr.AddLinkCommand.Execute(null);
var link = Assert.Single(ezr.Links);
link.Kind = link.Kinds.Single(k => k.Value == CatalogLinkKinds.Discord);
link.Url = "https://discord.gg/abc";
ezr.Theme = ezr.ThemeChoices.Single(c => c.Value == "neon");
ezr.Background = $"https://{api.BaseUrl.Host}/catalog/images/fond.jpg";
Assert.True(page.HasChanges);
Assert.True(ezr.IsDirty);
Assert.False(demo.IsDirty);
page.Selected = demo;
Assert.True(page.MoveUpCommand.CanExecute(null));
Assert.False(page.MoveDownCommand.CanExecute(null));
page.MoveUpCommand.Execute(null);
Assert.Equal(new[] { "DEMO", "EZR" }, page.Games.Select(g => g.GameId));
Assert.Same(demo, page.Selected);
Assert.True(page.Launcher.IsDirty);
// Valeur refusée : contrôlée AVANT l'envoi (mêmes règles que le serveur), rien n'est publié.
ezr.WebsiteUrl = "http://lanisys.fr";
await page.PublishCommand.ExecuteAsync();
Assert.Contains(dialogs.Messages, m => m.StartsWith("Valeurs à corriger") && m.Contains("https"));
Assert.False(File.Exists(publicFile));
ezr.WebsiteUrl = "";
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("révision 1", page.StatusMessage);
Assert.StartsWith("Révision 1, publiée le", page.RevisionText);
Assert.False(page.HasChanges);
Assert.False(ezr.IsDirty);
Assert.False(page.Launcher.IsDirty);
var file = JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(publicFile), ApiJson.Options)!;
Assert.Equal(1, file.Revision);
Assert.Equal(new[] { "DEMO", "EZR" }, file.Launcher!.GameOrder!);
Assert.Equal("Survivez à l'école.", file.Games["EZR"].Tagline);
Assert.Equal("neon", file.Games["EZR"].Theme);
Assert.Equal("https://discord.gg/abc", Assert.Single(file.Games["EZR"].Links!).Url);
Assert.False(file.Games.ContainsKey("DEMO")); // rien de modifié : valeurs de launcher.json
// Champ vidé = retour à la valeur de launcher.json.
Assert.Contains("Exovival Zombie Reborn", ezr.NameHint);
ezr.GameName = "EZR Reborn";
Assert.Equal("EZR Reborn", ezr.Title);
ezr.GameName = "";
Assert.Equal("Exovival Zombie Reborn", ezr.Title);
Assert.False(page.HasChanges);
}
[Fact]
public async Task Games_page_refuses_to_overwrite_a_catalog_published_in_the_meantime()
{
var publicFile = PublicFile();
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
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));
var page = await OpenGamesPageAsync(shell);
// Un autre administrateur publie pendant que la page est ouverte.
await using (var other = await api.LoginAsync(signer))
await other.PublishCatalogAsync(new CatalogDocument
{
Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Version de Paul" } }
}, baseRevision: 0);
var ezr = Assert.Single(page.Games);
ezr.Tagline = "Ma version";
await page.PublishCommand.ExecuteAsync();
Assert.True(page.HasConflict);
Assert.Contains(dialogs.Messages, m => m.StartsWith("Publié entre-temps"));
Assert.Equal("Version de Paul",
JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(publicFile), ApiJson.Options)!.Games["EZR"].Tagline);
// Recharger : confirmation (modifications perdues), puis la version publiée est affichée.
dialogs.ConfirmAnswer = false;
await page.ReloadCommand.ExecuteAsync();
Assert.Equal("Ma version", ezr.Tagline);
dialogs.ConfirmAnswer = true;
await page.ReloadCommand.ExecuteAsync();
Assert.Equal(2, dialogs.Confirmations.Count);
Assert.Equal("Version de Paul", ezr.Tagline);
Assert.False(page.HasConflict);
Assert.False(page.HasChanges);
Assert.StartsWith("Révision 1", page.RevisionText);
}
[Fact]
public async Task Theme_unknown_to_this_launcher_is_kept_and_not_reported_as_a_change()
{
var publicFile = PublicFile();
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
await using (var other = await api.LoginAsync(signer)) // publié par une version plus récente du launcher
await other.PublishCatalogAsync(new CatalogDocument
{
Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Theme = "aurora" } }
}, baseRevision: 0);
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), Modules));
var page = await OpenGamesPageAsync(shell);
var ezr = Assert.Single(page.Games);
Assert.Equal("aurora", ezr.Theme.Value);
Assert.Contains(ezr.Theme, ezr.ThemeChoices);
ezr.Theme = null!; // la liste de l'écran se vide lors d'un rechargement : ignoré
Assert.Equal("aurora", ezr.Theme.Value);
Assert.False(page.HasChanges);
}
[Fact]
public async Task Games_page_cannot_publish_when_the_server_has_no_public_file()
{
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(), Modules));
var page = await OpenGamesPageAsync(shell);
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.ShowPublishingDisabled);
Assert.Single(page.Games).Tagline = "Survivez.";
Assert.True(page.HasChanges);
Assert.False(page.PublishCommand.CanExecute(null));
}
}
@@ -0,0 +1,117 @@
using GameLauncher.Contracts.Catalog;
namespace GameLauncher.Tests.Catalog;
public class CatalogRulesTests
{
private static CatalogDocument Doc(CatalogGame game, string id = "EZR", CatalogLauncher? launcher = null) => new()
{
Revision = 3,
Launcher = launcher,
Games = new Dictionary<string, CatalogGame> { [id] = game }
};
[Fact]
public void Valid_values_are_kept_and_cleaned()
{
var errors = new List<string>();
var result = CatalogRules.Normalize(Doc(new CatalogGame
{
GameName = " Exovival Zombie ",
Tagline = "Survivez.",
Description = "Ligne 1\r\n\r\n - point\r\nLigne 3 ",
WebsiteUrl = "https://lanisys.fr",
Links = new() { new CatalogLink { Kind = CatalogLinkKinds.Discord, Url = "https://discord.gg/abc" } },
Theme = "exovival",
Icon = "https://launcher.example/catalog/images/ezr.png",
UpdateCheckIntervalMinutes = 0,
Hidden = false
}, launcher: new CatalogLauncher { LauncherName = "Lanisys", DefaultGame = "EZR", GameOrder = new() { "EZR", "ezr", "B" } }),
"launcher.example", errors);
Assert.Empty(errors);
var game = result.Games["ezr"]; // insensible à la casse
Assert.Equal("Exovival Zombie", game.GameName);
Assert.Equal("Ligne 1\n\n - point\nLigne 3", game.Description);
Assert.Equal("https://lanisys.fr/", game.WebsiteUrl);
Assert.Null(game.Hidden); // faux = valeur par défaut
Assert.Equal(new[] { "EZR", "B" }, result.Launcher!.GameOrder!);
Assert.Equal(3, result.Revision);
}
[Theory]
[InlineData("http://lanisys.fr")]
[InlineData("javascript:alert(1)")]
[InlineData("https://user:pass@lanisys.fr")]
[InlineData("file:///C:/Windows")]
[InlineData("lanisys.fr")]
public void Only_https_addresses_are_accepted(string url)
{
var errors = new List<string>();
var result = CatalogRules.Normalize(Doc(new CatalogGame { WebsiteUrl = url, Tagline = "ok" }), null, errors);
Assert.Single(errors);
Assert.Null(result.Games["EZR"].WebsiteUrl);
Assert.Equal("ok", result.Games["EZR"].Tagline); // le reste est conservé (le launcher ignore la valeur invalide)
}
[Theory]
[InlineData("https://autre.example/catalog/x.png")] // autre serveur
[InlineData("https://launcher.example/x.gif")] // format
[InlineData("https://launcher.example/x.png?v=1")] // paramètres
[InlineData("http://launcher.example/x.png")] // http
public void Images_must_be_png_or_jpeg_on_the_catalog_server(string url)
{
var errors = new List<string>();
var result = CatalogRules.Normalize(Doc(new CatalogGame { Icon = url, Background = url }), "launcher.example", errors);
Assert.Equal(2, errors.Count);
Assert.False(result.Games.ContainsKey("EZR"));
}
[Theory]
[InlineData("../../evil.xaml")]
[InlineData("C:\\theme.xaml")]
[InlineData("Exovival")]
[InlineData("theme.xaml")]
public void Theme_is_only_a_built_in_identifier(string theme)
{
var errors = new List<string>();
CatalogRules.Normalize(Doc(new CatalogGame { Theme = theme }), null, errors);
Assert.Single(errors);
}
[Fact]
public void Limits_are_enforced()
{
var errors = new List<string>();
var links = Enumerable.Range(0, 12).Select(i => new CatalogLink { Kind = CatalogLinkKinds.Other, Url = $"https://x.example/{i}" }).ToList();
var result = CatalogRules.Normalize(Doc(new CatalogGame
{
GameName = new string('a', 81),
Description = string.Join("\n", Enumerable.Repeat("ligne", 50)),
Links = links,
UpdateCheckIntervalMinutes = 3
}), null, errors);
Assert.Equal(4, errors.Count);
Assert.Equal(CatalogRules.MaxLinks, result.Games["EZR"].Links!.Count);
var invisible = new List<string>();
CatalogRules.Normalize(Doc(new CatalogGame { Tagline = "abc\u202Edef" }), null, invisible);
Assert.Single(invisible);
var badIds = new List<string>();
var ids = CatalogRules.Normalize(new CatalogDocument
{
Games = new Dictionary<string, CatalogGame> { ["../x"] = new() { Tagline = "a" }, ["ok"] = new() { Tagline = "b" } }
}, null, badIds);
Assert.Single(badIds);
Assert.Equal(new[] { "ok" }, ids.Games.Keys);
}
[Fact]
public void Unknown_format_or_version_is_reported()
{
var errors = new List<string>();
CatalogRules.Normalize(new CatalogDocument { Format = "autre", Version = 2 }, null, errors);
Assert.Equal(2, errors.Count);
}
}
@@ -0,0 +1,48 @@
using GameLauncher.Core.Services;
using GameLauncher.Tests.Infrastructure;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace GameLauncher.Tests.Catalog;
/// <summary>Téléchargements du catalogue et des images : redirections suivies sur le même serveur uniquement.</summary>
public class HttpDownloaderRedirectTests
{
[Fact]
public async Task Small_downloads_follow_same_host_redirects_and_never_contact_another_host()
{
var port = TestApi.FreePort();
var otherPort = TestApi.FreePort();
var otherHits = 0;
await using var other = Server(otherPort, app => app.MapGet("/x", () => { Interlocked.Increment(ref otherHits); return "autre"; }));
await using var main = Server(port, app =>
{
app.MapGet("/ok", () => "contenu");
app.MapGet("/moved", () => Results.Redirect("/ok"));
app.MapGet("/away", () => Results.Redirect($"http://127.0.0.1:{otherPort}/x"));
app.MapGet("/loop", () => Results.Redirect("/loop"));
});
await main.StartAsync();
await other.StartAsync();
using var http = new HttpDownloader("GameLauncher.Tests");
var root = new Uri($"http://127.0.0.1:{port}/");
Assert.Equal("contenu", System.Text.Encoding.UTF8.GetString(await http.GetBytesAsync(new Uri(root, "moved"), 1024, default)));
await Assert.ThrowsAsync<InvalidDataException>(() => http.GetBytesAsync(new Uri(root, "away"), 1024, default));
await Assert.ThrowsAsync<InvalidDataException>(() => http.GetBytesAsync(new Uri(root, "loop"), 1024, default));
Assert.Equal(0, otherHits); // autre port : aucune requête envoyée
}
private static WebApplication Server(int port, Action<WebApplication> map)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls($"http://127.0.0.1:{port}");
builder.Logging.ClearProviders();
var app = builder.Build();
map(app);
return app;
}
}
@@ -0,0 +1,213 @@
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Catalog;
using GameLauncher.Core.Models;
namespace GameLauncher.Tests.Catalog;
/// <summary>Catalogue côté joueur : téléchargement, cache, images, fusion avec launcher.json.</summary>
public class PlayerCatalogTests
{
private const string CatalogUrl = "https://launcher.example/catalog/catalog.json";
private static readonly string[] Themes = { "default", "exovival" };
private static readonly byte[] Png = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3 };
private static LauncherCatalog Launcher() => LauncherCatalog.Parse("""
{
"launcherName": "Lanisys Launcher", "publisher": "LanisysGames", "defaultGame": "EZR",
"catalogUrl": "https://launcher.example/catalog/catalog.json",
"games": [
{ "gameId": "EZR", "gameName": "Exovival Zombie Reborn", "manifestUrl": "https://launcher.example/ezr/manifest.json", "tagline": "Par défaut" },
{ "gameId": "B", "gameName": "Jeu B", "manifestUrl": "https://launcher.example/b/manifest.json" },
{ "gameId": "C", "gameName": "Jeu C", "manifestUrl": "https://launcher.example/c/manifest.json" }
]
}
""");
private static CatalogDocument Catalog() => new()
{
Revision = 4,
Launcher = new CatalogLauncher { LauncherName = "Lanisys", DefaultGame = "B", GameOrder = new() { "C", "EZR" } },
Games = new Dictionary<string, CatalogGame>
{
["EZR"] = new()
{
GameName = "Exovival", Tagline = "Survivez.", Description = "Ligne 1\nLigne 2", WebsiteUrl = "https://lanisys.fr/",
Links = new() { new CatalogLink { Kind = CatalogLinkKinds.Discord, Url = "https://discord.gg/abc" } },
Theme = "exovival", Icon = "https://launcher.example/catalog/images/ezr.png", UpdateCheckIntervalMinutes = 0
},
["B"] = new() { Hidden = true, Theme = "inconnu" },
["ZZZ"] = new() { GameName = "Jeu non déclaré" }
}
};
private static string TempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "launcher-catalog-player", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
[Fact]
public void Catalog_only_changes_display_of_declared_games()
{
var launcher = Launcher();
Assert.Equal(CatalogUrl, launcher.CatalogUrl);
var ezr = launcher.Find("EZR")!;
var dataFolderBefore = ezr.GameDataRelativePath;
CatalogMerger.Apply(launcher, Catalog(), Themes, url => url is null ? null : @"C:\cache\" + Path.GetFileName(url), _ => false);
Assert.Equal("Exovival", ezr.ShownName);
Assert.Equal("Exovival Zombie Reborn", ezr.GameName); // raccourci, registre, dossiers
Assert.Equal(dataFolderBefore, ezr.GameDataRelativePath); // jamais déplacé par le catalogue
Assert.Equal("Survivez.", ezr.Tagline);
Assert.Equal("Discord", Assert.Single(ezr.Links).Label);
Assert.Equal("exovival", ezr.Theme);
Assert.Equal(@"C:\cache\ezr.png", ezr.Icon);
Assert.Equal(0, ezr.UpdateCheckIntervalMinutes);
Assert.Equal("default", launcher.Find("B")!.Theme); // thème inconnu : ignoré
// Ordre du catalogue, B masqué, jeu non déclaré ignoré ; jeu par défaut masqué → celui de launcher.json.
Assert.Equal(new[] { "C", "EZR" }, launcher.Games.Select(g => g.GameId));
Assert.Equal(3, launcher.DeclaredGames.Count);
Assert.Equal("Lanisys", launcher.LauncherName);
Assert.Equal("EZR", launcher.DefaultGameId);
}
[Fact]
public void Hidden_game_stays_visible_when_installed_and_one_game_always_remains()
{
var launcher = Launcher();
CatalogMerger.Apply(launcher, Catalog(), Themes, _ => null, g => g.GameId == "B");
Assert.Equal(new[] { "C", "EZR", "B" }, launcher.Games.Select(g => g.GameId));
Assert.Equal("B", launcher.DefaultGameId);
var all = Launcher();
var hideAll = new CatalogDocument
{
Games = all.DeclaredGames.ToDictionary(g => g.GameId, _ => new CatalogGame { Hidden = true }, StringComparer.OrdinalIgnoreCase)
};
CatalogMerger.Apply(all, hideAll, Themes, _ => null, _ => false);
Assert.Equal("EZR", Assert.Single(all.Games).GameId);
}
[Fact]
public async Task Catalog_is_downloaded_validated_cached_and_used_offline()
{
var data = TempDirectory();
var bad = Catalog() with { };
bad.Games["EZR"] = bad.Games["EZR"] with { WebsiteUrl = "http://pas-https.example" };
var payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(bad, ApiJson.Options));
var requested = new List<string>();
var service = new CatalogService((uri, max, _) =>
{
requested.Add(uri.AbsoluteUri);
return uri.AbsoluteUri == CatalogUrl ? Task.FromResult(payload) : Task.FromResult(Png);
}, data, new Uri(CatalogUrl));
Assert.Null(service.LoadCached());
var fetched = await service.FetchAsync();
Assert.NotNull(fetched);
Assert.Null(fetched!.Games["EZR"].WebsiteUrl); // valeur invalide ignorée
Assert.Equal("Survivez.", fetched.Games["EZR"].Tagline);
Assert.Equal(4, service.LoadCached()!.Revision);
// Images : téléchargées une fois, contrôlées, puis servies depuis le cache.
var icon = fetched.Games["EZR"].Icon;
Assert.Null(service.CachedImage(icon));
var path = await service.GetImageAsync(icon);
Assert.NotNull(path);
Assert.Equal(path, service.CachedImage(icon));
Assert.Null(await service.GetImageAsync("https://ailleurs.example/x.png")); // autre serveur : refusé sans téléchargement
Assert.Equal(2, requested.Count);
// Hors ligne : le cache reste utilisable.
var offline = new CatalogService((_, _, _) => throw new HttpRequestException("hors ligne"), data, new Uri(CatalogUrl));
Assert.Null(await offline.FetchAsync());
Assert.Equal("Survivez.", offline.LoadCached()!.Games["EZR"].Tagline);
Assert.Equal(path, offline.CachedImage(icon));
}
[Fact]
public async Task Unusable_downloads_keep_the_previous_cache()
{
var data = TempDirectory();
var good = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(Catalog(), ApiJson.Options));
await new CatalogService((_, _, _) => Task.FromResult(good), data, new Uri(CatalogUrl)).FetchAsync();
foreach (var payload in new[]
{
Encoding.UTF8.GetBytes("{ pas du json"),
Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new CatalogDocument { Version = 99 }, ApiJson.Options))
})
{
var service = new CatalogService((_, _, _) => Task.FromResult(payload), data, new Uri(CatalogUrl));
Assert.Null(await service.FetchAsync());
Assert.Equal(4, service.LoadCached()!.Revision);
}
var notImage = new CatalogService((_, _, _) => Task.FromResult(Encoding.UTF8.GetBytes("<html>")), data, new Uri(CatalogUrl));
Assert.Null(await notImage.GetImageAsync("https://launcher.example/catalog/images/faux.png"));
}
[Fact]
public async Task Simultaneous_requests_for_an_image_share_one_download()
{
var data = TempDirectory();
var release = new TaskCompletionSource<byte[]>();
var downloads = 0;
var service = new CatalogService((_, _, _) =>
{
Interlocked.Increment(ref downloads);
return release.Task;
}, data, new Uri(CatalogUrl));
const string url = "https://launcher.example/catalog/images/fond.png";
var first = service.GetImageAsync(url); // démarrage (budget dépassé)
var second = service.GetImageAsync(url); // fin en arrière-plan
release.SetResult(Png);
var path = await first;
Assert.NotNull(path);
Assert.Equal(path, await second);
Assert.Equal(1, downloads);
Assert.Empty(Directory.GetFiles(service.ImagesDirectory, "*.tmp"));
}
[Fact]
public void Invalid_catalog_url_is_ignored_without_breaking_the_launcher()
{
var launcher = LauncherCatalog.Parse("""
{ "gameId": "EZR", "gameName": "Jeu", "manifestUrl": "https://launcher.example/m.json", "catalogUrl": "http://launcher.example/c.json" }
""");
Assert.Null(launcher.CatalogUrl);
Assert.Contains("catalogUrl ignorée", launcher.CatalogUrlError);
}
}
public class PlayerCatalogReapplyTests
{
[Fact]
public void Applying_a_newer_catalog_restores_values_it_no_longer_overrides()
{
var launcher = LauncherCatalog.Parse("""
{ "gameId": "EZR", "gameName": "Exovival", "manifestUrl": "https://launcher.example/m.json", "tagline": "Par défaut", "theme": "exovival" }
""");
var themes = new[] { "default", "exovival" };
CatalogMerger.Apply(launcher, new CatalogDocument
{
Launcher = new CatalogLauncher { LauncherName = "Ancien nom" },
Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Ancienne", Theme = "default", Description = "Texte" } }
}, themes, _ => null, _ => false);
Assert.Equal("Ancienne", launcher.Games[0].Tagline);
CatalogMerger.Apply(launcher, new CatalogDocument(), themes, _ => null, _ => false);
var game = launcher.Games[0];
Assert.Equal("Par défaut", game.Tagline);
Assert.Equal("exovival", game.Theme);
Assert.Null(game.Description);
Assert.Equal("Exovival", launcher.LauncherName);
}
}
@@ -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));
}
}
@@ -176,7 +176,7 @@ public sealed class TestApi : IAsyncDisposable
public static StringContent Json(object body) =>
new(JsonSerializer.Serialize(body, ApiJson.Options), Encoding.UTF8, "application/json");
private static int FreePort()
internal static int FreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
@@ -0,0 +1,183 @@
using System.Net;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Server;
public class CatalogModuleTests
{
private static string PublicDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-catalog-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return directory;
}
private static CatalogDocument Sample(string host, string tagline = "Survivez à l'école.") => new()
{
Launcher = new CatalogLauncher { LauncherName = "Lanisys Launcher", GameOrder = new() { "EZR" } },
Games = new Dictionary<string, CatalogGame>
{
["EZR"] = new()
{
GameName = "Exovival",
Tagline = tagline,
Links = new() { new CatalogLink { Kind = CatalogLinkKinds.Discord, Url = "https://discord.gg/abc" } },
Icon = $"https://{host}/catalog/images/ezr.png"
}
}
};
[Fact]
public async Task Catalog_is_published_to_the_public_file_with_revision_history_and_audit()
{
var publicDir = PublicDirectory();
var publicFile = Path.Combine(publicDir, "catalog.json");
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var initial = await owner.GetCatalogAsync();
Assert.True(initial.PublishingEnabled);
Assert.Equal(0, initial.Catalog.Revision);
Assert.EndsWith("/catalog/catalog.json", initial.PublicUrl);
var state = await owner.PublishCatalogAsync(Sample(api.BaseUrl.Host), baseRevision: 0);
Assert.Equal(1, state.Catalog.Revision);
Assert.NotNull(state.Catalog.UpdatedAt);
var file = JsonSerializer.Deserialize<CatalogDocument>(File.ReadAllText(publicFile), ApiJson.Options)!;
Assert.Equal(1, file.Revision);
Assert.Equal("Exovival", file.Games["EZR"].GameName);
Assert.DoesNotContain("Propriétaire", File.ReadAllText(publicFile)); // aucun nom d'administrateur dans le fichier public
Assert.True(File.Exists(Path.Combine(api.DataDirectory, "catalog.json")));
Assert.Single(Directory.GetFiles(Path.Combine(api.DataDirectory, "catalog-history")));
Assert.Empty(Directory.GetFiles(publicDir, "*.tmp", SearchOption.AllDirectories));
if (!OperatingSystem.IsWindows())
Assert.True(File.GetUnixFileMode(publicFile).HasFlag(UnixFileMode.OtherRead)); // lisible par nginx
var audit = await owner.GetAuditAsync();
Assert.Contains(audit.Entries, e => e.Action == "catalog.publish" && e.Details!.Contains("EZR"));
// Une seconde publication fondée sur la révision 0 est refusée : quelqu'un a publié entre-temps.
var conflict = await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(Sample(api.BaseUrl.Host, "autre"), 0));
Assert.Equal(409, conflict.Status);
Assert.Equal(ApiErrorCodes.RevisionConflict, conflict.Code);
var second = await owner.PublishCatalogAsync(Sample(api.BaseUrl.Host, "autre"), 1);
Assert.Equal(2, second.Catalog.Revision);
Assert.Equal("autre", (await owner.GetCatalogAsync()).Catalog.Games["EZR"].Tagline);
}
[Fact]
public async Task Invalid_catalogs_are_refused_and_nothing_is_written()
{
var publicFile = Path.Combine(PublicDirectory(), "catalog.json");
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
foreach (var game in new[]
{
new CatalogGame { WebsiteUrl = "http://lanisys.fr" },
new CatalogGame { Icon = "https://ailleurs.example/x.png" },
new CatalogGame { Theme = "../../theme.xaml" },
new CatalogGame { UpdateCheckIntervalMinutes = 2 }
})
{
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(
new CatalogDocument { Games = new Dictionary<string, CatalogGame> { ["EZR"] = game } }, 0));
Assert.Equal(400, refused.Status);
}
Assert.False(File.Exists(publicFile));
Assert.Equal(0, (await owner.GetCatalogAsync()).Catalog.Revision);
}
[Fact]
public async Task Failed_public_write_publishes_nothing()
{
var publicDir = PublicDirectory();
var publicFile = Path.Combine(publicDir, "catalog.json");
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
Directory.Delete(publicDir); // dossier public devenu inaccessible
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(Sample(api.BaseUrl.Host), 0));
Assert.Equal(503, refused.Status);
Assert.Equal(0, (await owner.GetCatalogAsync()).Catalog.Revision); // référence privée remise en état
Directory.CreateDirectory(publicDir);
Assert.Equal(1, (await owner.PublishCatalogAsync(Sample(api.BaseUrl.Host), 0)).Catalog.Revision);
if (!OperatingSystem.IsWindows())
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead,
File.GetUnixFileMode(publicFile));
}
[Fact]
public async Task Publishing_requires_configuration_and_a_signed_request()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var state = await owner.GetCatalogAsync();
Assert.False(state.PublishingEnabled);
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.PublishCatalogAsync(Sample(api.BaseUrl.Host), 0));
Assert.Equal(503, refused.Status);
Assert.Equal(ApiErrorCodes.NotConfigured, refused.Code);
// Sans signature : refusé avant tout traitement.
var raw = await api.RawLoginAsync(ownerKey);
using var response = await api.SendSignedRawAsync(raw, ownerKey, HttpMethod.Put, ApiRoutes.Catalog,
new CatalogUpdateRequest { Catalog = Sample(api.BaseUrl.Host) }, includeSignature: false);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Equal(ApiErrorCodes.SignatureRequired, (await TestApi.ReadErrorAsync(response)).Code);
}
[Fact]
public void Public_file_configuration_is_checked_at_startup()
{
var data = Path.Combine(Path.GetTempPath(), "launcher-api-tests", Guid.NewGuid().ToString("N"));
LauncherApiOptions Options(string file) => new()
{
PublicOrigin = "https://launcher.example",
DataDirectory = data,
Catalog = new LauncherApiOptions.CatalogSettings { PublicFile = file }
};
Assert.Throws<InvalidOperationException>(() => Options("catalog/catalog.json").Validate("/"));
Assert.Throws<InvalidOperationException>(() => Options(Path.Combine(PublicDirectory(), "catalog.txt")).Validate("/"));
Assert.Throws<InvalidOperationException>(() => Options(Path.Combine(PublicDirectory(), "absent", "catalog.json")).Validate("/"));
Directory.CreateDirectory(data);
Assert.Throws<InvalidOperationException>(() => Options(Path.Combine(data, "catalog.json")).Validate("/"));
var ok = Options(Path.Combine(PublicDirectory(), "catalog.json"));
ok.Validate("/");
Assert.Equal("https://launcher.example/catalog/catalog.json", ok.CatalogPublicUrl);
Assert.Equal("launcher.example", ok.CatalogImageHost);
// Données privées sous le dossier public (servi par nginx) : refusé aussi.
var publicRoot = PublicDirectory();
var nested = new LauncherApiOptions
{
PublicOrigin = "https://launcher.example",
DataDirectory = Path.Combine(publicRoot, "data"),
Catalog = new LauncherApiOptions.CatalogSettings { PublicFile = Path.Combine(publicRoot, "catalog.json") }
};
Directory.CreateDirectory(nested.DataDirectory);
Assert.Throws<InvalidOperationException>(() => nested.Validate("/"));
// Catalogue servi par un autre hôte : les images doivent venir de cet hôte-là.
var cdn = Options(Path.Combine(PublicDirectory(), "catalog.json"));
cdn.Catalog.PublicUrl = "https://cdn.example/catalog/catalog.json";
cdn.Validate("/");
Assert.Equal("cdn.example", cdn.CatalogImageHost);
}
}
@@ -0,0 +1,303 @@
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);
if (!OperatingSystem.IsWindows()) // comme sur le VPS : dossier setgid (groupe www-data transmis aux nouveaux fichiers)
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));
+55 -75
View File
@@ -1,102 +1,82 @@
# Version 1.6
# Version 1.7
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 31 octobre 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 apporte la vraie visée au pistolet, de nouveaux zombies plus effrayants et une exploration moins assistée : à vous d'ouvrir l'œil.
------------------------------------------------------------------
PROGRESSION : PROBLÈME BLOQUANT CORRIGÉ
PISTOLET : VISÉE PAR LE VISEUR
------------------------------------------------------------------
- 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 : clic droit maintenu pour viser. L'arme vient devant l'œil
et les organes de visée s'alignent au centre de l'écran, là où part
la balle.
* le guidon se place entre les deux points du cran de mire ;
* relâchez le clic droit pour revenir à la hanche.
- En visée, l'arme ne balance plus quand vous tournez la caméra : la
mire reste alignée pendant que vous suivez une cible.
- Le point blanc du centre de l'écran disparaît pendant la visée et
revient dès que vous relâchez.
- L'aide des commandes en haut à droite indique maintenant « Viser »
pour le clic droit (au lieu du contrôle du zoom).
------------------------------------------------------------------
CROCHETAGE
ZOMBIES
------------------------------------------------------------------
- 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.
- NOUVEAU : nouveaux modèles, plus détaillés, pour le zombie du
couloir et pour celui de la bibliothèque.
* leurs déplacements, leurs attaques et leur comportement ne
changent pas.
- NOUVEAU : tir à la tête. Une balle dans la tête fait deux fois plus
de dégâts, sur les deux zombies.
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.
Astuce : visez la tête, vos munitions dureront plus longtemps.
------------------------------------------------------------------
LA SILHOUETTE DU COULOIR
------------------------------------------------------------------
- NOUVEAU : la silhouette du couloir ouest change d'apparence.
- Le flash lumineux qui apparaissait à côté d'elle est supprimé : elle
se découpe maintenant sur un halo doux, sans source de lumière
visible.
------------------------------------------------------------------
OBJETS ET EXPLORATION
------------------------------------------------------------------
- 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.
- NOUVEAU : un petit cercle blanc s'affiche sur les objets que vous
pouvez prendre quand vous vous en approchez (2,5 m environ).
* piles, munitions, trousses de secours, pétrole, batteries ;
* outils, armes et sacs ;
* contenu des caisses et objets lâchés depuis l'inventaire.
- Les objets clés de l'histoire n'ont plus ni cercle ni reflet
lumineux : à vous de les repérer.
- Les panneaux d'aide fixés aux murs ont été retirés :
* au-dessus de la hache, dans le hall d'entrée ;
* au-dessus des interrupteurs du gymnase ;
* sur l'armoire de distribution électrique ;
* sur le coffret électrique de la bibliothèque.
------------------------------------------------------------------
LUMIÈRES : DURÉE RÉDUITE
GROUPE ÉLECTROGÈNE
------------------------------------------------------------------
- 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 !
- L'étiquette du groupe électrogène est mise à jour avec le nouvel
éclairage du gymnase.
* à allumer : circuits ÉCOLE et GYMNASE ;
* à laisser à l'arrêt : CANTINE et ÉCLAIRAGE EXTÉRIEUR.
- La note du concierge et la fiche de la remise parlent maintenant du
circuit GYMNASE.
------------------------------------------------------------------
INVENTAIRE
CORRIGÉ DEPUIS LA VERSION 1.6
------------------------------------------------------------------
- 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.
- La visée du pistolet ne fait plus un simple zoom : voir « Pistolet :
visée par le viseur ».
------------------------------------------------------------------
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.
Vos sauvegardes restent compatibles : les nouveaux modèles, la visée et les nouveaux textes s'appliquent aussi aux parties en cours.
+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