Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
001ab82fda | ||
|
|
67f4c02efc | ||
|
|
8808fe2615 | ||
|
|
bd5b9d46ca | ||
|
|
688b726e24 | ||
|
|
929b27dbeb | ||
|
|
090d8c64ec | ||
|
|
93f3ba630c |
No files matched your search
@@ -41,6 +41,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{1F9FBD
|
||||
server\deploy\LISEZMOI.md = server\deploy\LISEZMOI.md
|
||||
server\deploy\installer-api.sh = server\deploy\installer-api.sh
|
||||
server\deploy\launcher-api-catalog.conf = server\deploy\launcher-api-catalog.conf
|
||||
server\deploy\launcher-api-games.conf = server\deploy\launcher-api-games.conf
|
||||
server\deploy\launcher-api.service = server\deploy\launcher-api.service
|
||||
server\deploy\nginx-api.conf = server\deploy\nginx-api.conf
|
||||
server\deploy\nginx-catalog.conf = server\deploy\nginx-catalog.conf
|
||||
|
||||
@@ -35,10 +35,10 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
|
||||
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
|
||||
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
|
||||
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés (voir §8) |
|
||||
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, rôles, messages signés, catalogue 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 ; module « Catalogue des jeux » (`Catalog/`, page « Jeux ») (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
|
||||
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux ») 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 |
|
||||
@@ -50,7 +50,7 @@ src/GameLauncher/
|
||||
├─ Assets/ background.png (thème par défaut), Themes/exovival-background.jpg, launcher.ico
|
||||
├─ Themes/ Default.xaml et Exovival.xaml (thèmes), Icons.xaml (icônes), Controls.xaml (styles),
|
||||
│ Changelog.xaml (modèles des notes de version), Admin.xaml (menu d'administration,
|
||||
│ dont la page « Jeux »)
|
||||
│ dont les pages « Jeux » 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),
|
||||
@@ -72,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`) :
|
||||
@@ -474,8 +482,8 @@ launcher distribué : c'est l'identifiant de produit vérifié lors des mises à
|
||||
Chantier en cours, découpé en phases : architecture, sécurité et planning dans
|
||||
**`docs/ADMIN-ARCHITECTURE.md`**. Phase 1 livrée : le serveur d'administration et ses outils.
|
||||
Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitation, tableau de bord).
|
||||
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Les autres pages de gestion
|
||||
arrivent dans les phases suivantes.
|
||||
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
|
||||
**Notes de version**. 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é,
|
||||
@@ -505,6 +513,15 @@ arrivent dans les phases suivantes.
|
||||
signée) ; si quelqu'un a publié entre-temps, rien n'est écrasé et la page propose de recharger.
|
||||
Les jeux eux-mêmes (identifiant, manifest, exécutable, dossiers) restent dans `launcher.json` ;
|
||||
le nom affiché ne change ni le raccourci, ni le registre, ni les dossiers.
|
||||
* **Page « Notes de version »** (permission `changelog.edit`) : corrige les notes des versions **déjà
|
||||
publiées** dans le `manifest.json` du serveur. Liste des versions, en-tête (titre, sous-titre,
|
||||
introduction), sections › éléments (modification ou commentaire, étiquette, sous-points, note),
|
||||
boutons ↑ ↓ pour l'ordre, **Aperçu** avec la présentation exacte du launcher. Mêmes limites que le
|
||||
launcher, vérifiées avant l'envoi. « Enregistrer » ne change que les notes (fichiers, archive,
|
||||
version et exécutable restent identiques) ; si le manifest a changé entre-temps (nouvelle version
|
||||
publiée), rien n'est écrasé et la page propose de recharger. Une nouvelle version et ses notes se
|
||||
publient toujours avec ManifestBuilder (§3, `--remote-manifest`). Installation sur le VPS :
|
||||
`server/deploy/LISEZMOI.md`, étape 3 ter.
|
||||
* **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 :
|
||||
|
||||
+38
-14
@@ -68,7 +68,7 @@ Conséquences :
|
||||
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
|
||||
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
|
||||
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques |
|
||||
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
|
||||
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
|
||||
| `server/GameLauncher.Server` | net8.0 (ASP.NET Core) | **Nouveau.** Launcher API : modules serveur, authentification, administrateurs, audit, outil en ligne de commande |
|
||||
| `tools/AdminKeyTool` | net8.0 | **Nouveau.** Crée une clé d'administrateur (fichier chiffré) et teste une connexion à l'API |
|
||||
@@ -381,20 +381,44 @@ champ), pastille « modifié », contrôle complet avant l'envoi (mêmes règles
|
||||
suspendue pendant l'envoi, conflit de révision signalé avec rechargement proposé, bandeau si la
|
||||
publication n'est pas configurée.
|
||||
|
||||
## 10. Notes de version (phase 4)
|
||||
## 10. Notes de version (phase 4 ✅)
|
||||
|
||||
* Éditeur graphique dans le menu admin : arborescence version › sections › éléments, édition des
|
||||
champs du format enrichi, **aperçu en direct avec les mêmes modèles que le launcher**
|
||||
(`Themes/Changelog.xaml`) et les mêmes bornes (`ChangelogLimits`).
|
||||
* Éditeur graphique dans le menu admin (page « Notes de version », module `ChangelogAdminModule`,
|
||||
permission `changelog.edit`) : liste des versions › en-tête (titre, sous-titre, introduction) ›
|
||||
sections › éléments (modification ou commentaire, étiquette, sous-points, note), ordre ↑ ↓,
|
||||
**aperçu en direct avec les mêmes modèles que le launcher** (`Themes/Changelog.xaml`) et les mêmes
|
||||
bornes (`ChangelogLimits`). Une version à l'ancien format (liste à plat) est présentée en section(s)
|
||||
sans titre et enregistrée au format enrichi.
|
||||
* On édite **les versions existantes** uniquement ; la création d'une version reste au `.bat`.
|
||||
* Enregistrement : le launcher envoie le `manifest.json` modifié avec l'empreinte de la version
|
||||
qu'il a lue (concurrence optimiste) ; le serveur vérifie que **seules les notes ont changé**
|
||||
(fichiers, archive, version, exécutable identiques), valide, sauvegarde l'ancien manifest puis
|
||||
remplace atomiquement.
|
||||
* **Cohérence avec ManifestBuilder** : aujourd'hui il reprend l'historique des notes depuis le
|
||||
`manifest.json` *local*, ce qui écraserait les corrections faites en ligne. ManifestBuilder
|
||||
recevra une option `--remote-manifest <url>` (ajoutée au `.bat`) : l'historique est repris du
|
||||
serveur, seule la nouvelle version vient de `notes.txt`.
|
||||
* **Modèle partagé** : `ChangelogEntry`/`ChangelogSection`/`ChangelogItem` et `ChangelogLimits` sont
|
||||
dans `Contracts` (`GameLauncher.Contracts.Changelog`), utilisés par le launcher, ManifestBuilder, le
|
||||
menu d'administration et le serveur (qui ne référence pas `Core`). `ChangelogLimits.Check` signale
|
||||
les dépassements sans rien tronquer ; `Normalize` tronque (texte + « … » dans la longueur maximale).
|
||||
* **API** :
|
||||
* `GET v1/games/{gameId}/changelog` (administrateur connecté) : notes du manifest publié (liste à
|
||||
plat omise quand les sections existent) et **empreinte SHA-256** du manifest lu ;
|
||||
* `PUT v1/games/{gameId}/changelog/{version}` (`changelog.edit` + requête signée) : notes d'**une**
|
||||
version (60 Ko au plus, sous la limite de 64 Ko des requêtes) + empreinte de base. Identifiant et
|
||||
version : caractères sûrs uniquement (jamais utilisés dans un chemin).
|
||||
* **Serveur** (`Storage/ChangelogStore`) : manifest configuré par jeu (`LauncherApi:Games:<id>:ManifestFile`,
|
||||
chemin absolu, dossier existant, séparé des données privées ; `gameId` du manifest vérifié).
|
||||
Sous verrou : empreinte comparée (409 `revision_conflict` si le manifest a changé : nouvelle version
|
||||
publiée, autre administrateur), contrôle des bornes (400), entrée de la version remplacée dans
|
||||
l'arbre JSON (numéro, date et champs inconnus conservés, quelle que soit la casse ; liste à plat
|
||||
régénérée pour les anciens launchers), budget de tout l'historique vérifié, copie de l'ancien
|
||||
manifest dans les données privées (20 dernières), écriture d'un fichier temporaire caché du même
|
||||
dossier avec les droits du manifest existant, **empreinte revérifiée juste avant** le renommage
|
||||
atomique. Refus (503) si le manifest est un lien symbolique, ou si le dossier n'a pas le bit setgid
|
||||
alors que nginx lit le manifest par son groupe. Audit `changelog.edit`.
|
||||
* **Droits sur le VPS** : le service doit pouvoir écrire dans le dossier du jeu (complément systemd
|
||||
`ReadWritePaths`, `server/deploy/LISEZMOI.md` étape 3 ter). C'est un compromis : un service compromis
|
||||
pourrait alors modifier les fichiers du jeu servis aux joueurs (le manifest n'est pas encore signé,
|
||||
voir phase 7). L'autorisation est limitée au seul dossier du jeu ; sans `LauncherApi:Games`, rien
|
||||
n'est modifiable et le dossier reste en lecture seule pour le service.
|
||||
* **Cohérence avec ManifestBuilder** : option `--remote-manifest <url>` (ajoutée à
|
||||
`tools\publish-example.bat`) : l'historique est repris du manifest **en ligne**, seule la nouvelle
|
||||
version vient de `notes.txt`. 404 = première publication (historique local) ; toute autre erreur
|
||||
arrête la publication. Republier une version sans `--notes` garde ses notes existantes.
|
||||
|
||||
## 11. Publication d'une version (phase 5)
|
||||
|
||||
@@ -432,7 +456,7 @@ Le format des requêtes signées de la phase 1 est déjà compatible avec ce mod
|
||||
| **2** | Launcher : socle des modules (`GameLauncher.Modules`), module Administration (`GameLauncher.Modules.Admin`), entrée discrète (Ctrl+Maj+Alt+A ou 5 clics sur le logo), fenêtre d'administration (navigation, session, verrouillage auto), clé en fichier chiffré, connexion, « J'ai une invitation », tableau de bord | ✅ |
|
||||
| **2b** | Clé dans la puce TPM 2.0 (non exportable, code PIN vérifié par la puce), au choix en plus du fichier chiffré | ✅ |
|
||||
| **3** | Séparation fixe/modifiable : `catalog.json`, lecture + cache côté joueur, module serveur `Catalog`, page admin « Jeux » | ✅ |
|
||||
| **4** | Éditeur de notes de version + module serveur `Changelog` + option `--remote-manifest` de ManifestBuilder | à faire |
|
||||
| **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 |
|
||||
|
||||
@@ -26,7 +26,8 @@ public static class ApiModules
|
||||
new Modules.Auth.AuthModule(),
|
||||
new Modules.Admins.AdminsModule(),
|
||||
new Modules.Audit.AuditModule(),
|
||||
new Modules.Catalog.CatalogModule()
|
||||
new Modules.Catalog.CatalogModule(),
|
||||
new Modules.Changelog.ChangelogModule()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ public sealed class LauncherApiOptions
|
||||
|
||||
public CatalogSettings Catalog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
|
||||
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
|
||||
/// </summary>
|
||||
public Dictionary<string, GameSettings> Games { get; set; } = new();
|
||||
|
||||
/// <summary>Durée de validité d'un défi de connexion.</summary>
|
||||
public int ChallengeLifetimeSeconds { get; set; } = 60;
|
||||
|
||||
@@ -70,6 +76,17 @@ public sealed class LauncherApiOptions
|
||||
public string PublicUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>Un jeu publié sur ce serveur.</summary>
|
||||
public sealed class GameSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// manifest.json publié du jeu (chemin absolu dans le dossier servi par nginx, ex.
|
||||
/// « /srv/exovival/www/exovivalzombie/manifest.json »). Son dossier doit être autorisé en écriture
|
||||
/// par systemd (ReadWritePaths, voir server/deploy/LISEZMOI.md).
|
||||
/// </summary>
|
||||
public string ManifestFile { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class LimitSettings
|
||||
{
|
||||
/// <summary>Requêtes par minute et par adresse IP, toutes routes confondues.</summary>
|
||||
@@ -108,6 +125,10 @@ public sealed class LauncherApiOptions
|
||||
/// </summary>
|
||||
public string CatalogImageHost { get; private set; } = "";
|
||||
|
||||
/// <summary>manifest.json de chaque jeu configuré (chemins absolus), calculé par <see cref="Validate"/>.</summary>
|
||||
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
|
||||
public void Validate(string contentRoot)
|
||||
{
|
||||
@@ -135,6 +156,7 @@ public sealed class LauncherApiOptions
|
||||
TrustedProxyAddresses = proxies;
|
||||
|
||||
ValidateCatalog();
|
||||
ValidateGames();
|
||||
|
||||
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
|
||||
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
|
||||
@@ -159,13 +181,7 @@ public sealed class LauncherApiOptions
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:Catalog:PublicFile : le dossier « {directory} » n'existe pas.");
|
||||
// Ni le fichier public dans les données privées, ni les données privées sous le dossier public
|
||||
// (servi par nginx) ; liens symboliques résolus.
|
||||
var publicDirectory = RealPath(directory);
|
||||
var dataDirectory = RealPath(DataDirectoryFullPath);
|
||||
if (IsSameOrInside(publicDirectory, dataDirectory) || IsSameOrInside(dataDirectory, publicDirectory))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:Catalog:PublicFile : le dossier public et le dossier des données privées doivent être séparés (aucun des deux dans l'autre).");
|
||||
EnsureSeparatedFromData(directory, "Catalog:PublicFile");
|
||||
CatalogPublicFileFullPath = full;
|
||||
}
|
||||
|
||||
@@ -185,6 +201,43 @@ public sealed class LauncherApiOptions
|
||||
CatalogImageHost = new Uri(CatalogPublicUrl).Host;
|
||||
}
|
||||
|
||||
private void ValidateGames()
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (gameId, game) in Games)
|
||||
{
|
||||
var name = $"Games:{gameId}:ManifestFile";
|
||||
if (!Contracts.Catalog.CatalogRules.IsValidGameId(gameId))
|
||||
throw new InvalidOperationException($"{Section}:Games : identifiant de jeu invalide « {gameId} ».");
|
||||
if (string.IsNullOrWhiteSpace(game?.ManifestFile)) continue; // jeu déclaré sans manifest : ignoré
|
||||
var file = game.ManifestFile.Trim();
|
||||
if (!Path.IsPathRooted(file) || !file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException($"{Section}:{name} doit être un chemin absolu vers le manifest .json du jeu.");
|
||||
var full = Path.GetFullPath(file);
|
||||
var directory = Path.GetDirectoryName(full);
|
||||
if (directory is null || !Directory.Exists(directory))
|
||||
throw new InvalidOperationException($"{Section}:{name} : le dossier « {directory} » n'existe pas.");
|
||||
EnsureSeparatedFromData(directory, name);
|
||||
if (files.Values.Contains(full, StringComparer.Ordinal))
|
||||
throw new InvalidOperationException($"{Section}:{name} : ce manifest est déjà utilisé par un autre jeu.");
|
||||
files[gameId] = full;
|
||||
}
|
||||
GameManifestFiles = files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Un dossier public (servi par nginx) et le dossier des données privées ne doivent pas être l'un
|
||||
/// dans l'autre (liens symboliques résolus).
|
||||
/// </summary>
|
||||
private void EnsureSeparatedFromData(string directory, string name)
|
||||
{
|
||||
var publicDirectory = RealPath(directory);
|
||||
var dataDirectory = RealPath(DataDirectoryFullPath);
|
||||
if (IsSameOrInside(publicDirectory, dataDirectory) || IsSameOrInside(dataDirectory, publicDirectory))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:{name} : le dossier public et le dossier des données privées doivent être séparés (aucun des deux dans l'autre).");
|
||||
}
|
||||
|
||||
/// <summary>Chemin absolu, dernier élément résolu s'il s'agit d'un lien symbolique.</summary>
|
||||
private static string RealPath(string directory)
|
||||
{
|
||||
|
||||
@@ -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,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:<id>: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) { }
|
||||
}
|
||||
@@ -31,6 +31,10 @@
|
||||
"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,
|
||||
|
||||
@@ -124,6 +124,55 @@ 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 :
|
||||
@@ -191,7 +240,7 @@ d'autre ne peut approuver une clé à son nom. S'il a perdu tous ses PC : voir
|
||||
## 8. Mettre à jour le serveur
|
||||
|
||||
Refais les étapes 1 et 2. Les administrateurs, l'audit et `appsettings.Production.json` sont
|
||||
conservés, ainsi que le complément systemd du catalogue (`launcher-api.service.d/catalog.conf`) ;
|
||||
conservés, ainsi que les compléments systemd (`launcher-api.service.d/catalog.conf` et `games.conf`) ;
|
||||
les sessions ouvertes sont fermées (reconnexion). Si la nouvelle version ne démarre
|
||||
pas, le script remet la précédente en place.
|
||||
|
||||
@@ -243,10 +292,12 @@ recréer les administrateurs.
|
||||
├─ audit/audit-AAAA-MM.jsonl journal d'audit
|
||||
├─ catalog.json (+ .bak) catalogue publié (référence de la révision)
|
||||
├─ catalog-history/ 20 dernières versions publiées
|
||||
├─ manifest-history/<jeu>/ manifests remplacés 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é)
|
||||
```
|
||||
@@ -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
|
||||
@@ -8,6 +8,8 @@
|
||||
# Données privées : /var/lib/launcher-api (créé par systemd, droits 700).
|
||||
# Catalogue des jeux : écriture autorisée dans son seul dossier public par le complément
|
||||
# /etc/systemd/system/launcher-api.service.d/catalog.conf (launcher-api-catalog.conf, LISEZMOI.md).
|
||||
# Notes de version : écriture dans le dossier de chaque jeu par le complément games.conf
|
||||
# (launcher-api-games.conf, LISEZMOI.md étape 3 ter).
|
||||
#
|
||||
# Vérifier le durcissement : systemd-analyze security launcher-api
|
||||
# Journal : journalctl -u launcher-api -f
|
||||
|
||||
@@ -1,6 +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;
|
||||
|
||||
@@ -118,6 +119,27 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
SendSignedAsync<CatalogState>(HttpMethod.Put, ApiRoutes.Catalog,
|
||||
new CatalogUpdateRequest { BaseRevision = baseRevision, Catalog = catalog }, ct);
|
||||
|
||||
// ------------------------------------------------------------------ Notes de version (manifest.json publié)
|
||||
|
||||
/// <summary>Notes de version publiées d'un jeu, avec l'empreinte du manifest lu.</summary>
|
||||
public Task<ChangelogState> GetChangelogAsync(string gameId, CancellationToken ct = default) =>
|
||||
GetAsync<ChangelogState>(ApiRoutes.GameChangelog(CheckGameId(gameId)), ct);
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les notes d'UNE version existante (requête signée). <paramref name="baseSha256"/> : empreinte
|
||||
/// du manifest sur lequel la modification a été préparée (409 revision_conflict s'il a changé).
|
||||
/// </summary>
|
||||
public Task<ChangelogState> SaveChangelogEntryAsync(string gameId, ChangelogEntry entry, string baseSha256, CancellationToken ct = default)
|
||||
{
|
||||
if (!ChangelogLimits.IsEditableVersion(entry.Version))
|
||||
throw new ArgumentException($"Version « {entry.Version} » non modifiable en ligne.", nameof(entry));
|
||||
return SendSignedAsync<ChangelogState>(HttpMethod.Put, ApiRoutes.GameChangelogEntry(CheckGameId(gameId), entry.Version),
|
||||
new ChangelogUpdateRequest { BaseSha256 = baseSha256, Entry = entry }, ct);
|
||||
}
|
||||
|
||||
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) =>
|
||||
|
||||
@@ -39,4 +39,13 @@ public static class ApiRoutes
|
||||
|
||||
// ------------------------------------------------------------------ Catalogue (informations modifiables des jeux)
|
||||
public const string Catalog = "v1/catalog";
|
||||
|
||||
// ------------------------------------------------------------------ Notes de version (manifest.json de chaque jeu)
|
||||
public const string GameChangelogPattern = "v1/games/{gameId}/changelog";
|
||||
public const string GameChangelogEntryPattern = "v1/games/{gameId}/changelog/{version}";
|
||||
|
||||
/// <summary>Notes de version d'un jeu (identifiant et version : caractères sûrs uniquement, rien à encoder).</summary>
|
||||
public static string GameChangelog(string gameId) => $"v1/games/{gameId}/changelog";
|
||||
|
||||
public static string GameChangelogEntry(string gameId, string version) => $"v1/games/{gameId}/changelog/{version}";
|
||||
}
|
||||
+73
-4
@@ -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; }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Models;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
|
||||
namespace GameLauncher.Core.SelfUpdate;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Historique des notes de version lors d'une publication (ManifestBuilder). Avec --remote-manifest,
|
||||
/// l'historique est repris du manifest EN LIGNE : les corrections faites depuis le menu
|
||||
/// d'administration ne sont pas écrasées par le manifest.json local (resté dans son état d'origine).
|
||||
/// </summary>
|
||||
public static class ChangelogHistory
|
||||
{
|
||||
/// <summary>
|
||||
/// Manifest publié (null s'il n'existe pas encore : première publication). Toute autre erreur
|
||||
/// (réseau, fichier illisible) est signalée : publier sans elle perdrait les corrections en ligne.
|
||||
/// </summary>
|
||||
public static async Task<GameManifest?> FetchPublishedAsync(HttpDownloader http, Uri url, CancellationToken ct = default)
|
||||
{
|
||||
if (url.Scheme != Uri.UriSchemeHttps && !(url.Scheme == Uri.UriSchemeHttp && url.IsLoopback))
|
||||
throw new ArgumentException($"Adresse du manifest en ligne : https:// attendu ({url}).");
|
||||
|
||||
string json;
|
||||
try
|
||||
{
|
||||
json = await http.GetStringAsync(url, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<GameManifest>(json, JsonDefaults.Options)
|
||||
?? throw new InvalidDataException("manifest vide");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException($"manifest illisible : {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes de la version publiée en tête, puis l'historique (sans cette version si elle est republiée
|
||||
/// avec de nouvelles notes), bornés comme par le launcher. Sans nouvelles notes (pas de --notes),
|
||||
/// les notes existantes de la version, éventuellement corrigées en ligne, sont conservées.
|
||||
/// </summary>
|
||||
public static List<ChangelogEntry> Build(IEnumerable<ChangelogEntry?>? history, ChangelogEntry entry)
|
||||
{
|
||||
var list = (history ?? Enumerable.Empty<ChangelogEntry?>()).OfType<ChangelogEntry>().ToList();
|
||||
if (!entry.IsEmpty)
|
||||
{
|
||||
list.RemoveAll(c => string.Equals(c.Version, entry.Version, StringComparison.Ordinal));
|
||||
list.Insert(0, entry);
|
||||
}
|
||||
return ChangelogLimits.Normalize(list);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Notes de version » : page du menu d'administration pour corriger les notes des versions
|
||||
/// déjà publiées (manifest.json du serveur). Permission changelog.edit.
|
||||
/// </summary>
|
||||
public sealed class ChangelogAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "changelog";
|
||||
|
||||
public string DisplayName => "Notes de version";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
ChangelogPanelViewModel.PanelId,
|
||||
"Notes de version",
|
||||
"Corrigez les notes des versions déjà publiées : texte, sections, étiquettes, sous-points. " +
|
||||
"Une nouvelle version et ses notes se publient toujours avec ManifestBuilder (notes.txt).",
|
||||
"FileDocument",
|
||||
AdminPermissions.ChangelogEdit,
|
||||
Order: 20,
|
||||
context => new ChangelogPanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Notes d'une version, en cours de modification : en-tête (titre, sous-titre, introduction), puis
|
||||
/// sections › éléments. <see cref="Preview"/> est l'entrée telle que le launcher l'affichera (mêmes
|
||||
/// bornes, mêmes modèles d'affichage), recalculée à chaque modification.
|
||||
/// </summary>
|
||||
public sealed class VersionEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private readonly Func<string, string, bool> _confirm;
|
||||
private string _title = "", _subtitle = "", _introduction = "";
|
||||
private string _snapshot = "";
|
||||
private bool _isDirty, _showPreview, _loading;
|
||||
private ChangelogEntry _preview;
|
||||
private IReadOnlyList<string> _limitErrors = Array.Empty<string>();
|
||||
|
||||
/// <param name="confirm">Confirmation (titre, message) avant de retirer une section qui contient des éléments.</param>
|
||||
/// <param name="changed">Prévient la page (bouton « Enregistrer », pastilles).</param>
|
||||
public VersionEditor(ChangelogEntry published, Func<string, string, bool> confirm, Action changed)
|
||||
{
|
||||
Version = published.Version;
|
||||
Date = published.Date;
|
||||
_confirm = confirm;
|
||||
_changed = changed;
|
||||
_preview = new ChangelogEntry { Version = Version, Date = Date };
|
||||
Sections.CollectionChanged += OnSectionsChanged;
|
||||
AddSectionCommand = new RelayCommand(AddSection, () => Sections.Count < ChangelogLimits.MaxSectionsPerEntry);
|
||||
Load(published);
|
||||
}
|
||||
|
||||
public string Version { get; }
|
||||
public DateTimeOffset Date { get; }
|
||||
public string DateText => Date == default ? "" : _preview.DateText;
|
||||
|
||||
// ------------------------------------------------------------------ En-tête
|
||||
|
||||
public string Title { get => _title; set => Set(ref _title, value ?? ""); }
|
||||
|
||||
/// <summary>Sous-titre : une ou plusieurs lignes.</summary>
|
||||
public string Subtitle { get => _subtitle; set => Set(ref _subtitle, value ?? ""); }
|
||||
|
||||
/// <summary>Paragraphes d'introduction, séparés par une ligne vide.</summary>
|
||||
public string Introduction { get => _introduction; set => Set(ref _introduction, value ?? ""); }
|
||||
|
||||
public ObservableCollection<SectionEditor> Sections { get; } = new();
|
||||
public RelayCommand AddSectionCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
/// <summary>Modifié depuis le chargement (ou le dernier enregistrement).</summary>
|
||||
public bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
private set => SetProperty(ref _isDirty, value);
|
||||
}
|
||||
|
||||
/// <summary>Affichage : modification (faux) ou aperçu tel que le verront les joueurs (vrai).</summary>
|
||||
public bool ShowPreview
|
||||
{
|
||||
get => _showPreview;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _showPreview, value)) return;
|
||||
OnPropertyChanged(nameof(IsEditMode));
|
||||
OnPropertyChanged(nameof(IsPreviewMode));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEditMode { get => !_showPreview; set { if (value) ShowPreview = false; } }
|
||||
public bool IsPreviewMode { get => _showPreview; set { if (value) ShowPreview = true; } }
|
||||
|
||||
/// <summary>Notes telles que le launcher les affichera.</summary>
|
||||
public ChangelogEntry Preview
|
||||
{
|
||||
get => _preview;
|
||||
private set => SetProperty(ref _preview, value);
|
||||
}
|
||||
|
||||
/// <summary>Limites dépassées (rien n'est tronqué : à corriger avant d'enregistrer).</summary>
|
||||
public IReadOnlyList<string> LimitErrors
|
||||
{
|
||||
get => _limitErrors;
|
||||
private set
|
||||
{
|
||||
_limitErrors = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasLimitErrors));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasLimitErrors => _limitErrors.Count > 0;
|
||||
|
||||
/// <summary>« 3 sections · 14 éléments ».</summary>
|
||||
public string CountText
|
||||
{
|
||||
get
|
||||
{
|
||||
var items = Sections.Sum(s => s.Items.Count);
|
||||
return $"{Sections.Count} section{(Sections.Count > 1 ? "s" : "")} · {items} élément{(items > 1 ? "s" : "")} " +
|
||||
$"(au plus {ChangelogLimits.MaxItemsPerEntry})";
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Chargement, conversion
|
||||
|
||||
/// <summary>Affiche des notes publiées et en fait la référence « sans modification ».</summary>
|
||||
public void Load(ChangelogEntry entry)
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_title = entry.Title ?? "";
|
||||
_subtitle = entry.Subtitle ?? "";
|
||||
_introduction = string.Join("\n\n", entry.Introduction ?? new List<string>());
|
||||
Sections.Clear();
|
||||
// Ancien format (liste à plat) : présenté en section(s) sans titre, enregistré au format enrichi.
|
||||
var sections = entry.Sections.Count > 0
|
||||
? entry.Sections
|
||||
: (entry.Changes ?? new List<string>())
|
||||
.Chunk(ChangelogLimits.MaxItemsPerSection)
|
||||
.Select(chunk => new ChangelogSection { Items = chunk.Select(c => new ChangelogItem { Text = c }).ToList() })
|
||||
.ToList();
|
||||
foreach (var section in sections) Sections.Add(new SectionEditor(section, this));
|
||||
foreach (var name in new[] { nameof(Title), nameof(Subtitle), nameof(Introduction) }) OnPropertyChanged(name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
_snapshot = Snapshot(Normalized(ToEntry()));
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>Notes saisies (non tronquées) : c'est ce qui est envoyé au serveur.</summary>
|
||||
public ChangelogEntry ToEntry()
|
||||
{
|
||||
var entry = new ChangelogEntry
|
||||
{
|
||||
Version = Version,
|
||||
Date = Date,
|
||||
Title = Clean(Title),
|
||||
Subtitle = Clean(Subtitle.Replace("\r\n", "\n")),
|
||||
Introduction = Paragraphs(Introduction),
|
||||
Sections = Sections.Select(s => s.ToSection()).ToList()
|
||||
};
|
||||
// Liste à plat des anciens launchers : régénérée par le serveur à l'enregistrement (requête allégée).
|
||||
if (entry.Sections.Count == 0) entry.Changes = new List<string>();
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Modifications
|
||||
|
||||
internal void Changed()
|
||||
{
|
||||
if (_loading) return;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
var entry = ToEntry();
|
||||
var preview = Normalized(entry);
|
||||
var errors = ChangelogLimits.Check(entry);
|
||||
if (preview is null) errors.Insert(0, "Les notes ne peuvent pas être vides : gardez au moins un titre ou un élément.");
|
||||
LimitErrors = errors;
|
||||
Preview = preview ?? new ChangelogEntry { Version = Version, Date = Date };
|
||||
IsDirty = Snapshot(preview) != _snapshot;
|
||||
OnPropertyChanged(nameof(CountText));
|
||||
AddSectionCommand.RaiseCanExecuteChanged();
|
||||
_changed();
|
||||
}
|
||||
|
||||
private bool Set<T>(ref T field, T value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (!SetProperty(ref field, value, name)) return false;
|
||||
Changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void AddSection()
|
||||
{
|
||||
if (Sections.Count >= ChangelogLimits.MaxSectionsPerEntry) return;
|
||||
var section = new SectionEditor(new ChangelogSection(), this);
|
||||
section.AddChangeCommand.Execute(null);
|
||||
Sections.Add(section);
|
||||
}
|
||||
|
||||
internal void MoveSection(SectionEditor section, int delta) => ListMoves.Move(Sections, section, delta);
|
||||
|
||||
internal void RemoveSection(SectionEditor section)
|
||||
{
|
||||
if (section.Items.Any(i => !i.IsEmpty) &&
|
||||
!_confirm("Retirer la section ?", $"La section « {(string.IsNullOrWhiteSpace(section.Title) ? "sans titre" : section.Title.Trim())} » " +
|
||||
$"et ses {section.Items.Count} élément(s) seront retirés de ces notes (rien n'est enregistré avant « Enregistrer »)."))
|
||||
return;
|
||||
Sections.Remove(section);
|
||||
}
|
||||
|
||||
private void OnSectionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
foreach (var section in Sections) section.RefreshMoves();
|
||||
Changed();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private static ChangelogEntry? Normalized(ChangelogEntry entry) => ChangelogLimits.Normalize(new[] { entry }).FirstOrDefault();
|
||||
|
||||
private static string Snapshot(ChangelogEntry? entry) => entry is null ? "" : JsonSerializer.Serialize(entry, ApiJson.Options);
|
||||
|
||||
internal static string? Clean(string? text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
||||
|
||||
/// <summary>Paragraphes séparés par une (ou plusieurs) ligne(s) vide(s) ; retours à la ligne simples gardés en espaces.</summary>
|
||||
private static List<string> Paragraphs(string text) =>
|
||||
System.Text.RegularExpressions.Regex.Split(text.Replace("\r\n", "\n"), @"\n[ \t]*\n")
|
||||
.Select(p => Clean(p.Replace('\n', ' ')))
|
||||
.OfType<string>()
|
||||
.ToList();
|
||||
|
||||
/// <summary>Une ligne par élément (sous-points).</summary>
|
||||
internal static List<string> Lines(string text) =>
|
||||
text.Replace("\r\n", "\n").Split('\n').Select(Clean).OfType<string>().ToList();
|
||||
}
|
||||
|
||||
/// <summary>Section des notes (titre facultatif) et ses éléments.</summary>
|
||||
public sealed class SectionEditor : ObservableObject
|
||||
{
|
||||
private readonly VersionEditor _version;
|
||||
private string _title;
|
||||
|
||||
public SectionEditor(ChangelogSection section, VersionEditor version)
|
||||
{
|
||||
_version = version;
|
||||
_title = section.Title ?? "";
|
||||
AddChangeCommand = new RelayCommand(() => Add(comment: false), CanAdd);
|
||||
AddCommentCommand = new RelayCommand(() => Add(comment: true), CanAdd);
|
||||
foreach (var item in section.Items ?? new List<ChangelogItem>()) Items.Add(new ItemEditor(item, this));
|
||||
Items.CollectionChanged += (_, _) =>
|
||||
{
|
||||
foreach (var item in Items) item.RefreshMoves();
|
||||
OnPropertyChanged(nameof(CountText));
|
||||
AddChangeCommand.RaiseCanExecuteChanged();
|
||||
AddCommentCommand.RaiseCanExecuteChanged();
|
||||
_version.Changed();
|
||||
};
|
||||
MoveUpCommand = new RelayCommand(() => _version.MoveSection(this, -1), () => _version.Sections.IndexOf(this) > 0);
|
||||
MoveDownCommand = new RelayCommand(() => _version.MoveSection(this, +1),
|
||||
() => _version.Sections.IndexOf(this) is var i && i >= 0 && i < _version.Sections.Count - 1);
|
||||
RemoveCommand = new RelayCommand(() => _version.RemoveSection(this));
|
||||
}
|
||||
|
||||
/// <summary>Titre de la section (facultatif), ex. « PROGRESSION ».</summary>
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _title, value ?? "")) _version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<ItemEditor> Items { get; } = new();
|
||||
|
||||
public string CountText => $"{Items.Count} élément{(Items.Count > 1 ? "s" : "")}";
|
||||
|
||||
public RelayCommand AddChangeCommand { get; }
|
||||
public RelayCommand AddCommentCommand { get; }
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
internal VersionEditor Version => _version;
|
||||
|
||||
internal ChangelogSection ToSection() => new()
|
||||
{
|
||||
Title = VersionEditor.Clean(Title),
|
||||
Items = Items.Select(i => i.ToItem()).ToList()
|
||||
};
|
||||
|
||||
internal void RefreshMoves()
|
||||
{
|
||||
MoveUpCommand.RaiseCanExecuteChanged();
|
||||
MoveDownCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal void MoveItem(ItemEditor item, int delta) => ListMoves.Move(Items, item, delta);
|
||||
|
||||
internal void RemoveItem(ItemEditor item) => Items.Remove(item);
|
||||
|
||||
private bool CanAdd() => Items.Count < ChangelogLimits.MaxItemsPerSection;
|
||||
|
||||
private void Add(bool comment)
|
||||
{
|
||||
if (!CanAdd()) return;
|
||||
Items.Add(new ItemEditor(new ChangelogItem { Type = comment ? ChangelogItem.CommentType : null }, this));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Élément d'une section : une modification (étiquette, sous-points, note) ou un commentaire.</summary>
|
||||
public sealed class ItemEditor : ObservableObject
|
||||
{
|
||||
public static readonly IReadOnlyList<Choice> Kinds = new[]
|
||||
{
|
||||
new Choice(ChangelogItem.ChangeType, "Modification"),
|
||||
new Choice(ChangelogItem.CommentType, "Commentaire")
|
||||
};
|
||||
|
||||
private readonly SectionEditor _section;
|
||||
private Choice _kind;
|
||||
private string _text, _tag, _details, _note;
|
||||
|
||||
public ItemEditor(ChangelogItem item, SectionEditor section)
|
||||
{
|
||||
_section = section;
|
||||
_kind = item.IsComment ? Kinds[1] : Kinds[0];
|
||||
_text = item.Text ?? "";
|
||||
_tag = item.Tag ?? "";
|
||||
_details = string.Join("\n", item.Details ?? new List<string>());
|
||||
_note = item.Note ?? "";
|
||||
MoveUpCommand = new RelayCommand(() => _section.MoveItem(this, -1), () => _section.Items.IndexOf(this) > 0);
|
||||
MoveDownCommand = new RelayCommand(() => _section.MoveItem(this, +1),
|
||||
() => _section.Items.IndexOf(this) is var i && i >= 0 && i < _section.Items.Count - 1);
|
||||
RemoveCommand = new RelayCommand(() => _section.RemoveItem(this));
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> KindChoices => Kinds;
|
||||
|
||||
public Choice Kind
|
||||
{
|
||||
get => _kind;
|
||||
set
|
||||
{
|
||||
// null : liste de l'écran en cours de remplacement, pas un choix de l'administrateur.
|
||||
if (value is null || !SetProperty(ref _kind, value)) return;
|
||||
OnPropertyChanged(nameof(IsComment));
|
||||
OnPropertyChanged(nameof(IsChange));
|
||||
_section.Version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsComment => _kind.Value == ChangelogItem.CommentType;
|
||||
public bool IsChange => !IsComment;
|
||||
|
||||
public string Text { get => _text; set => Set(ref _text, value ?? ""); }
|
||||
|
||||
/// <summary>Étiquette en pastille, ex. « NOUVEAU » (modifications uniquement).</summary>
|
||||
public string Tag { get => _tag; set => Set(ref _tag, value ?? ""); }
|
||||
|
||||
/// <summary>Sous-points : un par ligne (modifications uniquement).</summary>
|
||||
public string Details { get => _details; set => Set(ref _details, value ?? ""); }
|
||||
|
||||
/// <summary>Texte de conclusion après les sous-points (modifications uniquement).</summary>
|
||||
public string Note { get => _note; set => Set(ref _note, value ?? ""); }
|
||||
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
internal bool IsEmpty => string.IsNullOrWhiteSpace(Text) && string.IsNullOrWhiteSpace(Details);
|
||||
|
||||
internal ChangelogItem ToItem() => IsComment
|
||||
? new ChangelogItem { Type = ChangelogItem.CommentType, Text = Text.Trim() }
|
||||
: new ChangelogItem
|
||||
{
|
||||
Text = Text.Trim(),
|
||||
Tag = VersionEditor.Clean(Tag),
|
||||
Details = VersionEditor.Lines(Details),
|
||||
Note = VersionEditor.Clean(Note)
|
||||
};
|
||||
|
||||
internal void RefreshMoves()
|
||||
{
|
||||
MoveUpCommand.RaiseCanExecuteChanged();
|
||||
MoveDownCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void Set(ref string field, string value, [System.Runtime.CompilerServices.CallerMemberName] string? name = null)
|
||||
{
|
||||
if (SetProperty(ref field, value, name)) _section.Version.Changed();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ListMoves
|
||||
{
|
||||
public static void Move<T>(ObservableCollection<T> list, T item, int delta)
|
||||
{
|
||||
var index = list.IndexOf(item);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= list.Count) return;
|
||||
list.Move(index, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Changelog;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Notes de version » : modification des notes des versions DÉJÀ publiées, dans le manifest.json
|
||||
/// du serveur (la création d'une version reste l'affaire de ManifestBuilder et du .bat).
|
||||
/// • liste des versions (pastille « modifié »), éditeur en-tête › sections › éléments, aperçu en
|
||||
/// direct avec les modèles d'affichage du launcher (Themes/Changelog.xaml) ;
|
||||
/// • mêmes bornes que le launcher, vérifiées avant l'envoi (rien n'est tronqué en silence) ;
|
||||
/// • « Enregistrer » : une requête signée par version modifiée, avec l'empreinte du manifest lu
|
||||
/// (si le manifest a changé entre-temps, rien n'est écrasé et le rechargement est proposé).
|
||||
/// </summary>
|
||||
public sealed class ChangelogPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "changelog.notes";
|
||||
|
||||
private ChangelogState? _state;
|
||||
private Choice? _selectedGame;
|
||||
private VersionEditor? _selectedVersion;
|
||||
private bool _hasChanges, _hasConflict;
|
||||
private string? _statusMessage;
|
||||
private int _skippedVersions;
|
||||
|
||||
public ChangelogPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
|
||||
_selectedGame = GameChoices.FirstOrDefault(g => string.Equals(g.Value, context.DefaultGameId, StringComparison.OrdinalIgnoreCase))
|
||||
?? GameChoices.FirstOrDefault();
|
||||
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && HasChanges && !HasConflict && _state is not null);
|
||||
ReloadCommand = new AsyncRelayCommand(ReloadAsync, () => !IsBusy && _selectedGame is not null);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Jeu
|
||||
|
||||
public IReadOnlyList<Choice> GameChoices { get; }
|
||||
public bool HasSeveralGames => GameChoices.Count > 1;
|
||||
|
||||
public Choice? SelectedGame
|
||||
{
|
||||
get => _selectedGame;
|
||||
set
|
||||
{
|
||||
if (value is null || Equals(value, _selectedGame)) return;
|
||||
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les notes modifiées de ce jeu n'ont pas été enregistrées : elles seront perdues.", "Abandonner et changer de jeu"))
|
||||
{
|
||||
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
|
||||
return;
|
||||
}
|
||||
_selectedGame = value;
|
||||
OnPropertyChanged();
|
||||
Clear(); // rien de l'ancien jeu ne reste affiché (ni enregistrable) si le chargement échoue
|
||||
_ = LoadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Versions
|
||||
|
||||
public ObservableCollection<VersionEditor> Versions { get; } = new();
|
||||
|
||||
public VersionEditor? SelectedVersion
|
||||
{
|
||||
get => _selectedVersion;
|
||||
set
|
||||
{
|
||||
// null : la liste de l'écran se vide lors d'un rechargement ; la sélection est rétablie ensuite.
|
||||
if (value is not null) SetProperty(ref _selectedVersion, value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasVersions => Versions.Count > 0;
|
||||
public bool ShowNoVersions => _state is not null && Versions.Count == 0;
|
||||
|
||||
/// <summary>« Version publiée : 1.1 · 6 versions avec des notes ».</summary>
|
||||
public string ManifestText => _state is null ? "" :
|
||||
$"Version publiée : {_state.ManifestVersion} · {Versions.Count} version{(Versions.Count > 1 ? "s" : "")} avec des notes" +
|
||||
(_skippedVersions > 0 ? $" ({_skippedVersions} non modifiable{(_skippedVersions > 1 ? "s" : "")} en ligne : numéro avec caractères spéciaux ou en double)" : "");
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
public bool HasChanges
|
||||
{
|
||||
get => _hasChanges;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasChanges, value)) SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Le manifest a changé sur le serveur (version publiée, autre administrateur) : il faut recharger.</summary>
|
||||
public bool HasConflict
|
||||
{
|
||||
get => _hasConflict;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasConflict, value)) SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(StatusMessage);
|
||||
|
||||
/// <summary>Faux pendant un chargement ou un enregistrement.</summary>
|
||||
public bool IsEditable => !IsBusy;
|
||||
|
||||
public AsyncRelayCommand SaveCommand { get; }
|
||||
public AsyncRelayCommand ReloadCommand { get; }
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
ReloadCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
public Task LoadAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_selectedGame?.Value is not { } gameId) return;
|
||||
var state = await Context.Session.GetChangelogAsync(gameId);
|
||||
Apply(state);
|
||||
StatusMessage = null;
|
||||
});
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
if (HasChanges && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les notes non enregistrées seront perdues : la page affichera celles du manifest publié.", "Abandonner et recharger"))
|
||||
return;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private void Apply(ChangelogState state)
|
||||
{
|
||||
var selected = _selectedVersion?.Version;
|
||||
_state = state;
|
||||
HasConflict = false;
|
||||
Versions.Clear();
|
||||
// Une version présente deux fois n'est modifiable qu'une fois (le serveur modifie la première).
|
||||
var editable = state.Entries.Where(e => ChangelogLimits.IsEditableVersion(e.Version))
|
||||
.DistinctBy(e => e.Version, StringComparer.Ordinal).ToList();
|
||||
_skippedVersions = state.Entries.Count - editable.Count;
|
||||
foreach (var entry in editable) Versions.Add(new VersionEditor(entry, Confirm, RefreshChanges));
|
||||
_selectedVersion = Versions.FirstOrDefault(v => v.Version == selected) ?? Versions.FirstOrDefault();
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
OnPropertyChanged(name);
|
||||
RefreshChanges();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Enregistrement
|
||||
|
||||
private Task SaveAsync() => RunAsync(async () =>
|
||||
{
|
||||
if (_state is null || _selectedGame?.Value is not { } gameId) return;
|
||||
var dirty = Versions.Where(v => v.IsDirty).ToList();
|
||||
|
||||
// Tout est vérifié avant le premier envoi : limites, taille de chaque requête.
|
||||
foreach (var version in dirty)
|
||||
{
|
||||
var entry = version.ToEntry();
|
||||
var errors = version.LimitErrors; // limites + notes vides
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
SelectedVersion = version;
|
||||
Context.Dialogs.ShowMessage($"Notes de la version {version.Version} à corriger",
|
||||
string.Join("\n", errors.Take(12)), ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
var size = JsonSerializer.SerializeToUtf8Bytes(new ChangelogUpdateRequest { BaseSha256 = _state.ManifestSha256, Entry = entry }, ApiJson.Options).Length;
|
||||
if (size > ChangelogLimits.MaxEditRequestBytes)
|
||||
{
|
||||
SelectedVersion = version;
|
||||
Context.Dialogs.ShowMessage("Notes trop longues pour l'édition en ligne",
|
||||
$"Les notes de la version {version.Version} font {size / 1024} Ko (au plus {ChangelogLimits.MaxEditRequestBytes / 1024} Ko par version). " +
|
||||
"Raccourcissez-les, ou modifiez notes.txt puis republiez la version.", ModuleMessageKind.Warning);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var saved = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var version in dirty)
|
||||
{
|
||||
var state = await Context.Session.SaveChangelogEntryAsync(gameId, version.ToEntry(), _state.ManifestSha256);
|
||||
_state = state;
|
||||
if (state.Entries.FirstOrDefault(e => e.Version == version.Version) is { } published) version.Load(published);
|
||||
saved.Add(version.Version);
|
||||
}
|
||||
StatusMessage = $"Notes enregistrées ({string.Join(", ", saved)}). Les launchers les afficheront à leur prochaine recherche de mise à jour.";
|
||||
Log.Info($"Administration : notes de version modifiées ({gameId} {string.Join(", ", saved)}).");
|
||||
}
|
||||
catch (ApiException ex) when (ex.Status == 409 && ex.Code == ApiErrorCodes.RevisionConflict)
|
||||
{
|
||||
HasConflict = true;
|
||||
if (saved.Count > 0) StatusMessage = $"Notes enregistrées : {string.Join(", ", saved)}.";
|
||||
Context.Dialogs.ShowMessage("Manifest modifié entre-temps",
|
||||
"Le manifest du jeu a changé sur le serveur (nouvelle version publiée ou autre administrateur). Rechargez la page " +
|
||||
"pour partir de la version publiée (les modifications non enregistrées seront perdues : notez-les si besoin).",
|
||||
ModuleMessageKind.Warning);
|
||||
}
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ Outils
|
||||
|
||||
private void Clear()
|
||||
{
|
||||
_state = null;
|
||||
HasConflict = false;
|
||||
StatusMessage = null;
|
||||
Versions.Clear();
|
||||
_selectedVersion = null;
|
||||
_skippedVersions = 0;
|
||||
foreach (var name in new[] { nameof(SelectedVersion), nameof(HasVersions), nameof(ShowNoVersions), nameof(ManifestText) })
|
||||
OnPropertyChanged(name);
|
||||
RefreshChanges();
|
||||
SaveCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void RefreshChanges() => HasChanges = Versions.Any(v => v.IsDirty);
|
||||
|
||||
private bool Confirm(string title, string message) => Context.Dialogs.Confirm(title, message, "Retirer", danger: true);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
@@ -504,7 +505,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private static ModuleCatalog CreateModules() => new(new ILauncherModule[]
|
||||
{
|
||||
new AdminModule(),
|
||||
new CatalogAdminModule()
|
||||
new CatalogAdminModule(),
|
||||
new ChangelogAdminModule()
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:GameLauncher.Controls"
|
||||
xmlns:avm="clr-namespace:GameLauncher.Modules.Admin.ViewModels;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:acat="clr-namespace:GameLauncher.Modules.Admin.Catalog;assembly=GameLauncher.Modules.Admin">
|
||||
xmlns:acat="clr-namespace:GameLauncher.Modules.Admin.Catalog;assembly=GameLauncher.Modules.Admin"
|
||||
xmlns:achl="clr-namespace:GameLauncher.Modules.Admin.Changelog;assembly=GameLauncher.Modules.Admin">
|
||||
|
||||
<!--
|
||||
Menu d'administration (Views/Admin) : styles communs et modèles des pages des modules.
|
||||
@@ -694,4 +695,319 @@
|
||||
</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>
|
||||
@@ -3,6 +3,7 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Services;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
|
||||
namespace GameLauncher.Views;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Changelog;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Changelog;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Server;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
/// <summary>Page « Notes de version » du menu d'administration.</summary>
|
||||
public class ChangelogPanelTests
|
||||
{
|
||||
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new ChangelogAdminModule() });
|
||||
|
||||
private static async Task<(TestApi Api, TempFolder Folder, FakeDialogs Dialogs, AdminShellViewModel Shell, ChangelogPanelViewModel Page)>
|
||||
OpenAsync(string? manifest)
|
||||
{
|
||||
var api = await TestApi.StartAsync(manifest is null
|
||||
? null
|
||||
: new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
|
||||
var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
signer.Dispose();
|
||||
var dialogs = new FakeDialogs();
|
||||
var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(ChangelogPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
return (api, folder, dialogs, shell, Assert.IsType<ChangelogPanelViewModel>(workspace.SelectedPanel!.ViewModel));
|
||||
}
|
||||
|
||||
private static GameManifest ReadManifest(string path) =>
|
||||
JsonSerializer.Deserialize<GameManifest>(File.ReadAllText(path), JsonDefaults.Options)!;
|
||||
|
||||
[Fact]
|
||||
public async Task Notes_are_edited_previewed_checked_and_saved_version_by_version()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.Equal(new[] { "1.1", "1.0" }, page.Versions.Select(v => v.Version));
|
||||
Assert.StartsWith("Version publiée : 1.1 · 2 versions", page.ManifestText);
|
||||
Assert.False(page.HasSeveralGames);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.False(page.SaveCommand.CanExecute(null));
|
||||
|
||||
// Version 1.1 : titre, nouvel élément avec étiquette et sous-points ; aperçu à jour.
|
||||
var v11 = page.SelectedVersion!;
|
||||
Assert.Equal("1.1", v11.Version);
|
||||
v11.Title = "Version 1.1 — corrigée";
|
||||
var section = Assert.Single(v11.Sections);
|
||||
section.AddChangeCommand.Execute(null);
|
||||
var item = section.Items[^1];
|
||||
item.Text = "Les portes se ferment correctement.";
|
||||
item.Tag = "CORRIGÉ";
|
||||
item.Details = "salle 2\n\nsalle 3\n";
|
||||
Assert.True(v11.IsDirty);
|
||||
Assert.True(page.HasChanges);
|
||||
Assert.Equal("Version 1.1 — corrigée", v11.Preview.Title);
|
||||
Assert.Equal(new[] { "salle 2", "salle 3" }, v11.Preview.Sections[0].Items[1].Details);
|
||||
Assert.True(section.Items[1].MoveUpCommand.CanExecute(null));
|
||||
Assert.False(section.Items[1].MoveDownCommand.CanExecute(null));
|
||||
|
||||
// Version 1.0 (ancien format : liste à plat) : présentée comme une section sans titre.
|
||||
var v10 = page.Versions[1];
|
||||
var legacy = Assert.Single(v10.Sections);
|
||||
Assert.Equal("", legacy.Title);
|
||||
Assert.Equal("Sortie du niveau 1.", Assert.Single(legacy.Items).Text);
|
||||
legacy.Items[0].Text = "Sortie du niveau 1 « L'école ».";
|
||||
|
||||
// Limite dépassée : signalée, rien n'est envoyé.
|
||||
item.Tag = new string('X', ChangelogLimits.MaxTagLength + 1);
|
||||
Assert.True(v11.HasLimitErrors);
|
||||
await page.SaveCommand.ExecuteAsync();
|
||||
Assert.Contains(dialogs.Messages, m => m.StartsWith("Notes de la version 1.1 à corriger"));
|
||||
Assert.Equal("Version 1.1", ReadManifest(manifest).Changelog[0].Title);
|
||||
|
||||
item.Tag = "CORRIGÉ";
|
||||
await page.SaveCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.Contains("1.1, 1.0", page.StatusMessage);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.False(v11.IsDirty);
|
||||
Assert.False(v10.IsDirty);
|
||||
|
||||
var saved = ReadManifest(manifest);
|
||||
Assert.Equal("Version 1.1 — corrigée", saved.Changelog[0].Title);
|
||||
Assert.Equal("CORRIGÉ", saved.Changelog[0].Sections[0].Items[1].Tag);
|
||||
Assert.Equal("Sortie du niveau 1 « L'école ».", saved.Changelog[1].Sections[0].Items[0].Text);
|
||||
Assert.Contains("Sortie du niveau 1 « L'école ».", saved.Changelog[1].Changes); // anciens launchers
|
||||
Assert.Equal("1.1", saved.Version);
|
||||
saved.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_manifest_changed_on_the_server_is_reported_and_reloaded()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
File.WriteAllText(manifest, File.ReadAllText(manifest).Replace("Première version", "Première version (WinSCP)"));
|
||||
page.SelectedVersion!.Title = "Ma correction";
|
||||
await page.SaveCommand.ExecuteAsync();
|
||||
Assert.True(page.HasConflict);
|
||||
Assert.Contains(dialogs.Messages, m => m.StartsWith("Manifest modifié entre-temps"));
|
||||
Assert.DoesNotContain("Ma correction", File.ReadAllText(manifest));
|
||||
|
||||
// Recharger : confirmation (modifications perdues), puis le manifest publié est affiché.
|
||||
dialogs.ConfirmAnswer = false;
|
||||
await page.ReloadCommand.ExecuteAsync();
|
||||
Assert.True(page.HasChanges);
|
||||
dialogs.ConfirmAnswer = true;
|
||||
await page.ReloadCommand.ExecuteAsync();
|
||||
Assert.False(page.HasConflict);
|
||||
Assert.False(page.HasChanges);
|
||||
Assert.Equal("1.1", page.SelectedVersion!.Version); // même version resélectionnée
|
||||
Assert.Equal("Première version (WinSCP)", page.Versions[1].Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Removing_a_section_with_items_asks_first()
|
||||
{
|
||||
var manifest = ChangelogModuleTests.PublishedManifest();
|
||||
var (api, folder, dialogs, shell, page) = await OpenAsync(manifest);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
var version = page.SelectedVersion!;
|
||||
version.AddSectionCommand.Execute(null);
|
||||
Assert.Equal(2, version.Sections.Count);
|
||||
version.Sections[1].RemoveCommand.Execute(null); // vide : retirée sans question
|
||||
Assert.Single(version.Sections);
|
||||
Assert.Empty(dialogs.Confirmations);
|
||||
|
||||
dialogs.ConfirmAnswer = false;
|
||||
version.Sections[0].RemoveCommand.Execute(null);
|
||||
Assert.Single(version.Sections);
|
||||
Assert.Single(dialogs.Confirmations);
|
||||
dialogs.ConfirmAnswer = true;
|
||||
version.Sections[0].RemoveCommand.Execute(null);
|
||||
Assert.Empty(version.Sections);
|
||||
Assert.True(version.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_game_without_configured_manifest_shows_why()
|
||||
{
|
||||
var (api, folder, _, shell, page) = await OpenAsync(manifest: null);
|
||||
await using var _api = api;
|
||||
using var _folder = folder;
|
||||
await using var _shell = shell;
|
||||
|
||||
Assert.True(page.HasError);
|
||||
Assert.Contains("non configuré", page.ErrorMessage);
|
||||
Assert.False(page.HasVersions);
|
||||
Assert.False(page.SaveCommand.CanExecute(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
Reference in new issue
Block a user