Compare commits

...
14 Commits
Author SHA1 Message Date
nicolas 51eecbb57a Merge pull request 'Dev/3.3.1' (#139) from dev/3.3.1 into master
Reviewed-on: #139
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 12:30:21 +01:00
nicolas edd2633455 chore(release): launcher 3.3.1 2026-11-10 12:18:55 +01:00
nicolas 0e26e6eb11 docs: rafraîchissement du trousseau (3.3.1) 2026-11-10 12:18:22 +01:00
nicolas 916b53446a fix(trust): retélécharger le trousseau quand une clé connue n'est pas autorisée, âge max 30 min 2026-11-10 12:17:50 +01:00
nicolas 0e371518b5 Merge pull request 'Dev/3.3.0' (#138) from dev/3.3.0 into master
Reviewed-on: #138
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 11:50:25 +01:00
nicolas 153e498345 Tests et documentation du mode maintenance
- 11 nouveaux tests (367 au total) ; SigningPanelTests : ligne « Mode maintenance » en dernier
- README, ADMIN-ARCHITECTURE (§ 7, § 11 septies, § 13), notes-launcher 3.3.0
2026-11-10 11:40:43 +01:00
nicolas 1e9f49a733 ui-snapshot : scènes du mode maintenance
main-maintenance, main-maintenance-plain, admin-page-maintenance, admin-page-maintenance-edit ;
routes v1/maintenance simulées ; ligne « Mode maintenance » dans admin-page-signing
2026-11-10 11:37:50 +01:00
nicolas 5f6c667030 Launcher 3.3.0 : mode maintenance
- Thème rouge / orange, carte « Maintenance en cours » au centre, bouton « MAINTENANCE »
- Tout est bloqué sauf la désinstallation et le menu d'administration
  (commandes désactivées + refus à l'entrée de chaque action)
- État lu avant la recherche des mises à jour (4 s au plus), puis relu périodiquement
- Page « Maintenance » et bandeau du tableau de bord (Admin.xaml), icône TrafficCone
- Version 3.3.0
2026-11-10 11:37:28 +01:00
nicolas 93dd844221 Menu admin : page « Maintenance »
- Activer (confirmation), modifier le message, terminer ; contrôle du trousseau des joueurs
- Le launcher de ce PC relit l'état aussitôt (NotifyMaintenanceChanged)
- Tableau de bord : bandeau « Maintenance en cours »
- Page « Signature » : ligne « Mode maintenance » ; journal d'audit : catégorie dédiée
2026-11-10 11:36:01 +01:00
nicolas 5ad8025946 Core : lecture et vérification du mode maintenance ; client de l'API
- MaintenanceService : lecture au démarrage puis toutes les 1 à 2 min,
  signature vérifiée (contentSigning), retour en arrière refusé, hors ligne = pas de maintenance
- ContentTrustService.VerifyMaintenanceAsync
- AdminSession : GetMaintenanceAsync, PublishMaintenanceAsync
2026-11-10 11:34:55 +01:00
nicolas 080000e13c Déploiement : bloc nginx maintenance.json et étape 3 decies 2026-11-10 11:34:30 +01:00
nicolas 3422b92f81 Serveur : module Maintenance (GET / PUT v1/maintenance)
- Publication signée (maintenance.manage + document signé par la clé de la session)
- Révision attendue (409), début inchangé pendant une maintenance
- Fichier public maintenance.json à côté du trousseau, historique de 20 révisions
- Audit maintenance.enable / update / disable
- GET v1/signing renvoie la signature du mode maintenance
2026-11-10 11:34:12 +01:00
nicolas 683cc7a9dc Contrats : document du mode maintenance, permission et autorisation de signature
- MaintenanceDocument / MaintenanceState / MaintenancePublishRequest
- MaintenanceRules : règles communes (message 300 car., 6 lignes, sans lien, condensé signé)
- Permission maintenance.manage (Propriétaire, Administrateur sur installation neuve)
- Type de contenu et autorisation du trousseau « maintenance »
- Route v1/maintenance ; SigningState.Maintenance
2026-11-10 11:33:04 +01:00
nicolas 7bc229de53 Merge pull request 'Dev/3.2.1' (#136) from dev/3.2.1 into master
Reviewed-on: #136
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 10:03:15 +01:00
49 changed files with 2813 additions and 118 deletions

No files matched your search

+19 -4
View File
@@ -32,7 +32,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| Projet | Rôle |
|---|---|
| `src/GameLauncher` | Application WPF (fenêtres, thème, ViewModels, registre, raccourci) |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`), mode maintenance (`Maintenance/`) |
| `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, groupes et permissions, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
@@ -52,7 +52,8 @@ 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 les pages « Jeux », « Notes de version » et « Publier une version »)
│ dont les pages « Jeux », « Notes de version » et « Publier une version »),
│ Maintenance.xaml (palette rouge / orange du mode maintenance)
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow,
│ FeedbackWindow (bouton « Aide »)
│ └─ Admin/ AdminWindow, LoginView, EnrollView (menu d'administration ; ViewModels dans GameLauncher.Modules.Admin)
@@ -512,7 +513,7 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
servi aux joueurs (page **Signature**, voir plus bas). Ajoutées ensuite : la page **Fichiers du serveur**, puis le
bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode maintenance** (voir plus bas).
* **Ergonomie commune aux pages** : les boutons d'action (« Recharger », « Publier », « Enregistrer »,
« Actualiser », « Exporter… ») restent affichés en bas de la fenêtre, quelle que soit la position dans la
@@ -520,6 +521,18 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
molette fait défiler ce qui est sous le curseur : elle ne change jamais une liste déroulante fermée (même si
elle vient d'être utilisée), et une liste ou un champ qui ne peut plus défiler laisse défiler la page.
* **Page « Maintenance »** (permission `maintenance.manage`) : **active ou désactive le mode maintenance de tous les
launchers** (tous les jeux), avec une précision facultative pour les joueurs (6 lignes, 300 caractères, aucun lien ;
aperçu en direct). Chez les joueurs : fond et accents **rouge / orange**, carte « Maintenance en cours — merci de
revenir plus tard » au centre (avec la précision et l'heure de début), bouton principal « MAINTENANCE » ; installation,
mises à jour (automatiques comprises), vérification, réparation, localisation, lancement (raccourcis compris),
paramètres, bouton « Aide » et site officiel **bloqués** ; seules la **désinstallation** (menu « … » ou Windows) et le
menu d'administration restent disponibles. Lu au démarrage avant la recherche des mises à jour, puis toutes les 2 min
(1 min pendant une maintenance) ; le launcher de l'administrateur bascule aussitôt. **Signé** (autorisation « mode
maintenance » du trousseau) et vérifié comme le catalogue ; hors ligne au démarrage : pas de maintenance. Les
téléchargements commencés se terminent, les parties en cours ne sont pas fermées, les mises à jour du launcher
lui-même continuent. Le tableau de bord rappelle une maintenance en cours. Mise en place : `server/deploy/LISEZMOI.md`,
étape 3 decies.
* **Bouton « Aide »** (joueurs, barre de titre, bulle avec un point d'interrogation) : **avis**, **demande
d'évolution**, **bug** ou **autre**, pour le **jeu sélectionné** (affiché, non modifiable pendant la saisie). Champs
prédéfinis selon le type (note en étoiles, catégorie, gravité, fréquence, étapes pour reproduire…), contrôlés avant
@@ -627,7 +640,9 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
reste jouable), et un catalogue refusé est ignoré. Le trousseau est retéléchargé sans redémarrer le launcher quand
un contenu est signé par une clé absente ou non autorisée pour ce contenu (au plus une fois par minute), et au plus
toutes les 30 minutes (une clé retirée n'est plus acceptée par un launcher laissé ouvert). Après le retrait d'une clé compromise, relevez
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
+83 -3
View File
@@ -68,7 +68,7 @@ Conséquences :
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
| `src/GameLauncher.Modules.Feedback` | net8.0 | **Nouveau (7d).** Module « Aide » côté joueur : formulaire de demande (avis, évolution, bug, autre) généré à partir des formulaires partagés, envoi anonyme (§ 11 quinquies) |
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) ; **7f** : lecture et vérification du mode maintenance (`Maintenance/`) |
| `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 |
@@ -324,6 +324,8 @@ premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les
| `files.manage` | ✔ | ✔ | | Fichiers du serveur : consultation du site, suppression des fichiers inutilisés, nettoyage d'« objects » |
| `feedback.read` | ✔ | ✔ | | Demandes des joueurs : lecture (consultations inscrites au journal) — § 11 quinquies |
| `feedback.manage` | ✔ | ✔ | | Demandes des joueurs : statut, note interne, suppression |
| `feedback.forms` | ✔ | ✔ | | Formulaires des demandes : modification et publication signée — § 11 sexies |
| `maintenance.manage` | ✔ | ✔ | | Mode maintenance du launcher : activation, désactivation, message (publication signée) — § 11 septies |
Une permission intégrée ajoutée après l'installation (ex. `feedback.*`) est donnée **automatiquement** au groupe
Propriétaire seulement : les groupes existants ne changent pas d'eux-mêmes (aucune élévation de droits sans l'action
@@ -866,6 +868,78 @@ autorisation « formulaires »), signature par la clé de ce PC, requête signé
« Recharger », rien n'est écrasé). « Annuler les modifications », « Revenir à l'origine » (formulaires d'origine dans
l'éditeur, à publier ensuite), « Recharger ». Libellé d'audit : « Formulaires des demandes publiés ».
### 11 septies. Mode maintenance du launcher (7f ✅)
**Besoin** : depuis le menu, un bouton pour mettre **tous les launchers** (tous les jeux) en maintenance : thème rouge /
orange, « Maintenance en cours — merci de revenir plus tard » au centre, et **tout bloqué** sauf la **désinstallation
des jeux** et le **menu d'administration** ; puis la terminer.
**Document** (`Contracts/Maintenance/MaintenanceDocument`) : `maintenance.json` = format `gamelauncher-maintenance`,
version 1, **révision**, `enabled`, `message` (facultatif), `since` (début, à la seconde ; absent hors maintenance),
`updatedAt` (posé par le serveur, hors signature), signature ; révision 0 = jamais publié (inactif). Règles
(`MaintenanceRules`, communes au menu, au serveur et au launcher) : format et version, révision ≥ 1 ; message
nettoyé comme les textes des demandes (`FeedbackRules.CleanMultiline` : caractères de contrôle, invisibles, de mise en
forme, d'inversion du sens d'écriture, d'usage privé refusés), **300 caractères, 6 lignes, aucun lien**
(`FeedbackLinks`) ; maintenance active : début présent, à la seconde, pas plus de 10 min dans le futur (serveur) ;
inactive : ni début ni message ; **8 Ko** au plus (sous la limite de 64 Ko de l'API : pas de bloc nginx d'API). Titre et
sous-titre affichés aux joueurs **fixes** (dans le code) : seul le message est publié. Condensé signé : format, version,
révision, état, message, début.
**Signature** : type de contenu `maintenance` (identifiant signé `maintenance`) et autorisation du trousseau
**`maintenance`** (permission `maintenance.manage` : Propriétaire d'office, Administrateur sur une installation neuve,
pas l'Éditeur). Les launchers 3.2 acceptent un trousseau qui la contient (autorisation inconnue sans effet) ; ceux
d'avant le refusent et gardent le précédent (publier la 3.3 avant de re-signer). Un serveur compromis ne peut donc ni
déclencher une fausse maintenance, ni prolonger une maintenance chez les launchers en « require » (date de signature
non antérieure à la dernière acceptée, révision non inférieure).
| Route | Accès | Signée | Rôle |
|---|---|:-:|---|
| `GET v1/maintenance` | administrateur connecté | | État en vigueur, publication possible, adresse publique, signataire (tableau de bord) |
| `PUT v1/maintenance` | `maintenance.manage` | ✔ | `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 `revision_conflict` sinon), début inchangé pendant une maintenance ; audit `maintenance.enable`, `maintenance.disable` ou `maintenance.update` |
**Serveur** (`Modules/Maintenance/MaintenanceModule`, `Storage/MaintenanceStore`) : référence privée `maintenance.json`
(+ `.bak`, historique de 20 révisions `maintenance-history/`), copie **publique** à côté du trousseau
(`MaintenancePublicFileFullPath`, 644, atomique ; public d'abord) ; nom réservé (refus au démarrage si le catalogue ou
le trousseau s'appellent ainsi). Document signé obligatoire. Le serveur ne bloque rien lui-même : l'API reste utilisable
(c'est elle qui termine la maintenance). `GET v1/signing` renvoie aussi sa signature (`SigningState.Maintenance`).
SQLite écarté : un seul petit document, publié en fichier de toute façon. nginx : `location = /catalog/maintenance.json`,
sans cache.
**Launcher (joueur)** : `Core/Maintenance/MaintenanceService` lit `maintenance.json` (adresse déduite du trousseau,
sinon du catalogue) **au démarrage**, en parallèle du catalogue et de la mise à jour du launcher, et l'attend **4 s au
plus avant la recherche des mises à jour des jeux** (pas d'attente pour `--uninstall`) ; puis toutes les **2 min** (1 min
pendant une maintenance) et aussitôt après une publication depuis le menu de ce PC (`IAdminContext.NotifyMaintenanceChanged`).
8 Ko au plus, 8 s au plus ; règles contrôlées même si signé ; signature vérifiée par
`ContentTrustService.VerifyMaintenanceAsync` (autorisation `maintenance`, date de signature non antérieure) ; révision
inférieure à l'état connu refusée ; même fichier que la fois précédente (déjà accepté) : rien à revérifier ; fichier
refusé : revérifié à chaque relecture (un trousseau re-signé avec l'autorisation `maintenance` pendant que le launcher
tourne est retéléchargé et pris en compte, 3.3.1). **Rien n'est gardé sur le
disque** : injoignable au démarrage = pas de maintenance (le jeu installé reste jouable hors ligne, un joueur sans
connexion n'est jamais bloqué) ; pendant que le launcher tourne, une coupure ou un fichier refusé garde le dernier état
vérifié, un 404 termine la maintenance.
Blocage (`LauncherViewModel.ApplyMaintenance`, `GameViewModel.IsMaintenance`) : commandes désactivées (sauf
`UninstallCommand`, l'annulation d'une opération commencée et la fermeture du bandeau) **et** refus à l'entrée de chaque
action (installation, mise à jour, réparation, vérification, localisation, oubli, lancement, raccourci du Bureau,
paramètres, recherche de mises à jour, `RunOperationAsync` sauf la désinstallation) avec un bandeau d'explication ;
recherche automatique et mise à jour automatique suspendues, relancées à la fin de la maintenance ; paramètres du
launcher, bouton « Aide » et site officiel masqués ou désactivés. Non interrompus : opération déjà commencée (toujours
annulable), jeu déjà lancé, mise à jour du launcher lui-même (signée par la clé racine : moyen de corriger un launcher
défectueux). Affichage : `Themes/Maintenance.xaml` (palette rouge / orange, mêmes clés que les thèmes) ajouté aux
ressources de la fenêtre principale seulement, carte au centre (cône de chantier `Icon.TrafficCone`, bandes de
chantier, titre, sous-titre, message en texte brut sélectionnable, « Depuis le … », rappel « la désinstallation reste
possible »), bouton principal « MAINTENANCE » désactivé ; début et fin avec le fondu enchaîné d'un changement de jeu.
**Ce n'est pas une barrière de sécurité** : un launcher modifié peut l'ignorer ; les serveurs du jeu doivent refuser
eux-mêmes les connexions si nécessaire.
**Menu d'administration** : module `MaintenanceAdminModule`, page **« Maintenance »** (`MaintenancePanelViewModel`,
permission `maintenance.manage`, ordre 5, icône cône) : état publié (en cours depuis…, par…, terminée le…), message
(compteur, contrôle en direct, aperçu de la carte), « Activer la maintenance » (confirmation détaillée), « Modifier le
message », « Terminer la maintenance » (confirmation), contrôle du trousseau des joueurs avant chaque publication
(`PlayerSigningCheck`), 409 : état relu, saisie gardée. Tableau de bord : bandeau « Maintenance en cours » pour tout
administrateur (bouton « Gérer » avec la permission). Page « Signature » : ligne « Mode maintenance ». Journal d'audit :
catégorie « Mode maintenance ».
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
@@ -944,8 +1018,12 @@ Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (s
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
l'appelant : le cache est utilisé. Nouveau téléchargement du trousseau (mêmes contrôles) : après un échec réseau
(1 min), quand un contenu est signé par une clé absente du trousseau **ou présente sans l'autorisation de ce contenu**
(clé ajoutée ou autorisation donnée depuis : au plus une fois par minute, `KeyChangeRetryDelay`), et quand le trousseau
en mémoire a plus de 30 minutes (`KeyringMaxAge`, à la vérification suivante : un launcher laissé ouvert cesse de faire
confiance à une clé retirée). Avant la 3.3.1, une autorisation ajoutée à une clé déjà connue n'était vue qu'au
redémarrage (mode maintenance refusé à chaque relecture après la re-signature du trousseau). Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
@@ -976,6 +1054,8 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
| **7b** | Page « Fichiers du serveur » (suppression des fichiers inutilisés, nettoyage d'« objects »), période du journal d'audit, barres d'actions fixes et retour en haut, molette sans effet sur les listes fermées | ✅ |
| **7c** | Groupes d'administrateurs et permissions personnalisées (page « Groupes et permissions », module serveur `Groups`, `admins.json` format 2) ; envoi des images du catalogue depuis la page « Jeux » (« Parcourir… », images nommées par leur empreinte et vérifiées par les joueurs) | ✅ |
| **7d** | Demandes des joueurs : bouton « Aide » (avis, évolution, bug, autre ; anonyme, preuve de travail, limites), module serveur `Feedback`, page « Demandes des joueurs », purge automatique (demandes et journal d'audit) | ✅ |
| **7e** | Formulaires des demandes modifiables depuis le menu (types, champs, icônes importées), signés (autorisation `forms`) | ✅ |
| **7f** | Mode maintenance du launcher : page « Maintenance », module serveur `Maintenance`, document signé (autorisation `maintenance`), blocage côté joueur sauf la désinstallation, thème rouge / orange | ✅ |
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
@@ -32,7 +32,8 @@ public static class ApiModules
new Modules.Publishing.PublishingModule(),
new Modules.Signing.SigningModule(),
new Modules.Files.FilesModule(),
new Modules.Feedback.FeedbackModule()
new Modules.Feedback.FeedbackModule(),
new Modules.Maintenance.MaintenanceModule()
};
}
@@ -248,6 +248,15 @@ public sealed class LauncherApiOptions
/// <summary>Adresse publique des formulaires des demandes, calculée par <see cref="Validate"/>.</summary>
public string FeedbackFormsPublicUrl { get; private set; } = "";
/// <summary>
/// Fichier public du mode maintenance (maintenance.json, à côté du trousseau ; null : publication désactivée), calculé
/// par <see cref="Validate"/>. Les launchers le lisent dans le même dossier que le trousseau.
/// </summary>
public string? MaintenancePublicFileFullPath { get; private set; }
/// <summary>Adresse publique du mode maintenance, calculée par <see cref="Validate"/>.</summary>
public string MaintenancePublicUrl { get; private set; } = "";
/// <summary>Racine du site public pour la page « Fichiers du serveur » (null : page désactivée), calculée par <see cref="Validate"/>.</summary>
public string? FilesRootFullPath { get; private set; }
@@ -403,11 +412,24 @@ public sealed class LauncherApiOptions
? Path.Combine(Path.GetDirectoryName(keyringFile)!, FeedbackFormsFileName)
: null;
FeedbackFormsPublicUrl = new Uri(new Uri(KeyringPublicUrl), FeedbackFormsFileName).AbsoluteUri;
// Mode maintenance : même règle (les launchers en déduisent l'adresse à partir de celle du trousseau).
MaintenancePublicFileFullPath = KeyringPublicFileFullPath is { } keyringPath
? Path.Combine(Path.GetDirectoryName(keyringPath)!, MaintenanceFileName)
: null;
MaintenancePublicUrl = new Uri(new Uri(KeyringPublicUrl), MaintenanceFileName).AbsoluteUri;
if (MaintenancePublicFileFullPath is { } maintenance &&
new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Any(f => string.Equals(f, maintenance, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException(
$"{Section}:Keyring:PublicFile / Catalog:PublicFile : « {MaintenanceFileName} » est réservé au mode maintenance, choisissez un autre nom.");
}
/// <summary>Nom du fichier public des formulaires (à côté de keyring.json).</summary>
public const string FeedbackFormsFileName = "feedback-forms.json";
/// <summary>Nom du fichier public du mode maintenance (à côté de keyring.json).</summary>
public const string MaintenanceFileName = "maintenance.json";
private void ValidateGames()
{
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -0,0 +1,104 @@
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
namespace GameLauncher.Server.Modules.Maintenance;
/// <summary>
/// Mode maintenance du launcher (commun à tous les jeux, <see cref="MaintenanceStore"/>) :
/// • GET v1/maintenance : état en vigueur, pour tout administrateur connecté (contenu public de toute façon) ;
/// • PUT v1/maintenance : activation, désactivation ou nouveau message — permission maintenance.manage, requête signée
/// ET document signé par la clé de la session (autorisation « maintenance » du trousseau, vérifiée par les launchers),
/// règles complètes, révision attendue (409 si quelqu'un a publié entre-temps), audit.
/// Le serveur ne bloque rien lui-même : ce sont les launchers des joueurs qui, en lisant le fichier public, bloquent tout
/// sauf la désinstallation des jeux et le menu d'administration. L'API d'administration reste utilisable pendant la
/// maintenance (c'est elle qui permet de la terminer).
/// </summary>
public sealed class MaintenanceModule : IApiModule
{
public string Name => "Maintenance";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
services.AddSingleton<MaintenanceStore>();
public void MapEndpoints(IEndpointRouteBuilder api)
{
api.MapGet(ApiRoutes.Maintenance, Get).RequireAdmin();
api.MapPut(ApiRoutes.Maintenance, Publish).RequirePermission(AdminPermissions.MaintenanceManage).RequireSignedRequest();
}
private static IResult Get(MaintenanceStore store, AdminStore admins, ILogger<MaintenanceModule> logger)
{
try
{
return Results.Json(State(store, admins), ApiJson.Options);
}
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
{
logger.LogError(ex, "Lecture du mode maintenance");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
"dans les données privées du serveur.");
}
}
private static MaintenanceState State(MaintenanceStore store, AdminStore admins)
{
var current = store.Current;
return new MaintenanceState
{
Document = current,
PublishingEnabled = store.PublishingEnabled,
PublicUrl = store.PublicUrl,
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
};
}
private static IResult Publish(MaintenancePublishRequest request, HttpContext context, MaintenanceStore store, AdminStore admins,
AuditLog audit, TimeProvider time, ILogger<MaintenanceModule> logger)
{
if (request.Document is null) return ApiResults.BadRequest("Document de maintenance absent.");
var document = request.Document with { UpdatedAt = null };
var actor = context.Admin();
if (document.Signature is not { } signature)
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Le mode maintenance doit être signé.");
var now = time.GetUtcNow();
ContentSignatures.RequireFromActor(signature, ContentKinds.Maintenance, ContentKinds.MaintenanceId,
MaintenanceRules.Digest(document), actor, now);
MaintenanceDocument published, previous;
try
{
(published, previous) = store.Publish(document, request.BaseRevision, now);
}
catch (InvalidDataException ex)
{
logger.LogError(ex, "Publication du mode maintenance");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/.");
}
catch (IOException ex)
{
logger.LogError(ex, "Publication du mode maintenance");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire le fichier public du mode maintenance : vérifiez le dossier et ses droits " +
"(server/deploy/LISEZMOI.md).");
}
var action = published.Enabled switch
{
true when !previous.Enabled => "maintenance.enable",
false when previous.Enabled => "maintenance.disable",
_ => "maintenance.update"
};
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", $"{MaintenanceRules.Describe(published)} ; signé (clé {signature.KeyId})");
logger.LogInformation("Mode maintenance {Action} par {Admin} (révision {Revision})", action, actor.Admin.DisplayName, published.Revision);
return Results.Json(State(store, admins), ApiJson.Options);
}
}
@@ -3,6 +3,7 @@ using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
@@ -14,7 +15,8 @@ namespace GameLauncher.Server.Modules.Signing;
/// <summary>
/// Signature du contenu servi aux joueurs (phase 7) :
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests, du catalogue et des formulaires des demandes ;
/// • GET v1/signing (admins.manage) : trousseau publié, signatures des manifests, du catalogue, des formulaires des demandes
/// et du mode maintenance ;
/// • PUT v1/keyring (admins.manage, requête signée) : publication d'un trousseau signé HORS LIGNE par la clé racine ;
/// • GET / POST v1/games/{gameId}/signature (releases.publish) : signature de la version déjà en ligne.
/// La signature des nouvelles versions et du catalogue se fait à leur publication (modules Publishing et Catalog).
@@ -34,8 +36,8 @@ public sealed class SigningModule : IApiModule
api.MapPost(ApiRoutes.ManifestSignaturePattern, SignManifest).RequirePermission(AdminPermissions.ReleasesPublish).RequireSignedRequest();
}
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, FeedbackFormsStore forms, AdminStore admins,
IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
private static IResult State(KeyringStore keyrings, CatalogStore catalogs, FeedbackFormsStore forms, MaintenanceStore maintenance,
AdminStore admins, IOptions<LauncherApiOptions> options, ILogger<SigningModule> logger)
{
var settings = options.Value;
var db = admins.Read();
@@ -103,6 +105,31 @@ public sealed class SigningModule : IApiModule
}
}
// Mode maintenance en vigueur (référence privée, recopiée à l'identique dans le fichier public).
MaintenanceSignatureState? maintenanceState = null;
if (maintenance.PublishingEnabled)
{
try
{
var current = maintenance.Current;
maintenanceState = current.Revision == 0
? new MaintenanceSignatureState()
: new MaintenanceSignatureState
{
Revision = current.Revision,
Enabled = current.Enabled,
Digest = MaintenanceRules.Digest(current),
Signature = current.Signature,
SignerName = ContentSignatures.SignerName(db, current.Signature)
};
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or JsonException)
{
logger.LogWarning(ex, "Signature du mode maintenance");
maintenanceState = new MaintenanceSignatureState { Unreadable = true };
}
}
KeyringDocument? keyring;
try
{
@@ -123,7 +150,8 @@ public sealed class SigningModule : IApiModule
RequireSignatures = settings.ContentSigning.Require,
Manifests = manifests,
Catalog = catalog,
FeedbackForms = feedbackForms
FeedbackForms = feedbackForms,
Maintenance = maintenanceState
}, ApiJson.Options);
}
@@ -0,0 +1,133 @@
using System.Text;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Mode maintenance du launcher (commun à tous les jeux), activé ou désactivé depuis le menu d'administration.
/// • Référence : maintenance.json dans les données privées (+ historique des 20 dernières révisions) ;
/// • Publication : copie dans le fichier PUBLIC servi par nginx, à côté du trousseau (les launchers en déduisent
/// l'adresse), écrite atomiquement, lisible par tous ;
/// • Toujours signé (autorisation « maintenance » du trousseau) : le serveur vérifie que la signature est celle de la
/// session et que le document respecte les règles, le launcher vérifie la signature avec le trousseau.
/// Rien de publié : maintenance inactive (révision 0).
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
/// </summary>
public sealed class MaintenanceStore
{
public const string FileName = "maintenance.json";
private const string HistoryFolder = "maintenance-history";
private const int HistoryLength = 20;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private readonly string _path;
private readonly string _lockPath;
private readonly string _historyDirectory;
private readonly string? _publicFile;
private readonly object _lock = new();
private MaintenanceDocument? _current;
public MaintenanceStore(IOptions<LauncherApiOptions> options)
: this(options.Value.DataDirectoryFullPath, options.Value.MaintenancePublicFileFullPath, options.Value.MaintenancePublicUrl) { }
public MaintenanceStore(string dataDirectory, string? publicFile, string? publicUrl = null)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
_publicFile = publicFile;
PublicUrl = publicUrl;
}
public bool PublishingEnabled => _publicFile is not null;
public string? PublicUrl { get; }
/// <summary>État en vigueur : celui publié, sinon maintenance inactive (révision 0).</summary>
/// <exception cref="InvalidDataException">Fichier de référence illisible.</exception>
public MaintenanceDocument Current
{
get
{
lock (_lock) return _current ??= Read() ?? MaintenanceRules.Defaults();
}
}
private MaintenanceDocument? Read()
{
if (!File.Exists(_path)) return null;
try
{
return MaintenanceRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
}
catch (System.Text.Json.JsonException ex)
{
throw new InvalidDataException($"{FileName} illisible : {ex.Message}", ex);
}
}
/// <summary>
/// Publie un document signé (signature déjà contrôlée par l'appelant) : règles complètes, révision attendue (409 si
/// une autre publication a eu lieu), révision suivante obligatoire. Pendant une maintenance, le début annoncé ne
/// change pas (modification du message seulement).
/// </summary>
/// <returns>Document publié (avec la date) et celui d'avant.</returns>
public (MaintenanceDocument Published, MaintenanceDocument Previous) Publish(MaintenanceDocument document, long baseRevision, DateTimeOffset now)
{
if (_publicFile is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication du mode maintenance non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
"LauncherApi:Catalog:PublicFile) est nécessaire, le mode maintenance est publié à côté.");
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Mode maintenance non signé.");
var errors = new List<string>();
if (!MaintenanceRules.Validate(document, errors, now))
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)));
lock (_lock)
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
{
var previous = Read() ?? MaintenanceRules.Defaults();
if (baseRevision != previous.Revision)
throw ApiRejection.Conflict(
$"Le mode maintenance a été modifié entre-temps (révision {previous.Revision}) : rechargez la page, puis recommencez.",
ApiErrorCodes.RevisionConflict);
if (document.Revision != previous.Revision + 1)
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
if (document.Enabled && previous.Enabled && document.Since != previous.Since)
throw ApiRejection.BadRequest("Maintenance déjà en cours : sa date de début ne change pas (seul le message peut être modifié).");
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
var json = MaintenanceRules.Serialize(published);
if (Encoding.UTF8.GetByteCount(json) > MaintenanceRules.MaxBytes)
throw ApiRejection.BadRequest($"Mode maintenance trop volumineux (au plus {MaintenanceRules.MaxBytes / 1024} Ko).");
// Public d'abord : en cas d'échec, la référence privée (qui porte la révision) reste l'ancienne.
PublicFileWriter.Write(_publicFile, json, "fichier public du mode maintenance");
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
WriteHistory(published.Revision, json);
_current = published;
return (published, previous);
}
}
private void WriteHistory(long revision, string json)
{
try
{
SecureFiles.EnsureDirectory(_historyDirectory);
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"maintenance-{revision:D6}.json"), json, keepBackup: false);
foreach (var old in Directory.GetFiles(_historyDirectory, "maintenance-*.json").OrderByDescending(f => f, StringComparer.Ordinal)
.Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// L'historique est une aide : son échec n'annule pas la publication.
}
}
}
+52 -2
View File
@@ -286,8 +286,11 @@ Limites :
textes) ;
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
et au moins celui du dernier trousseau qu'il a vu ;
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
* un appareil ajouté au trousseau, ou une autorisation ajoutée à un appareil, pendant qu'un launcher tourne est
connu de ce launcher dès le contenu suivant signé par cet appareil (nouveau téléchargement du trousseau, au plus une
fois par minute) ;
* un launcher laissé ouvert retélécharge le trousseau toutes les 30 minutes au plus (à la vérification suivante) : un
appareil retiré, ou une autorisation enlevée, n'y est plus accepté sans attendre son redémarrage.
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
@@ -454,6 +457,50 @@ joueur remplit le sien, le serveur répond 409 : le launcher 3.2 relit les formu
inchangés. Chaque demande conserve le formulaire tel qu'à l'envoi : elle reste lisible si un champ est renommé ou
supprimé ensuite. Historique des 20 dernières publications : `feedback-forms-history/` dans les données privées.
## 3 decies. Mode maintenance du launcher (PC et VPS)
Le menu d'administration › page **« Maintenance »** active ou désactive le mode maintenance de **tous les launchers**
(tous les jeux) : fond et accents rouge / orange, « Maintenance en cours — merci de revenir plus tard » au centre avec,
au choix, une précision (6 lignes, 300 caractères, **aucun lien**). Pendant la maintenance, les joueurs ne peuvent plus
installer, mettre à jour, vérifier, réparer, localiser ni lancer un jeu, ni ouvrir les paramètres ou la fenêtre « Aide » :
seuls la **désinstallation** et le menu d'administration restent disponibles. Le document est **signé** : un serveur
compromis ne peut ni déclencher une fausse maintenance ni la prolonger chez les launchers en mode `require`.
Prérequis : étapes 3 bis (catalogue) et 3 quinquies (trousseau).
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le fichier est écrit à
côté du trousseau (`/srv/exovival/www/catalog/maintenance.json`, 644), dans le dossier déjà autorisé en écriture
(`catalog.conf`, étape 3 bis).
2. **nginx** : ajoute le bloc `location = /catalog/maintenance.json { … }` de `server/deploy/nginx-catalog.conf`, à côté
de celui du trousseau (aucun cache : la fin de la maintenance doit être vue tout de suite), puis
`sudo nginx -t && sudo systemctl reload nginx`. Aucun bloc à ajouter dans `nginx-api.conf` (petite requête).
3. **Launcher 3.3** : publie-le (mise à jour signée habituelle) **avant** l'étape 4. Les launchers 3.2 acceptent le
nouveau trousseau (autorisation inconnue d'eux, sans effet) ; les launchers antérieurs à la 3.2 le refusent et
gardent le précédent, comme pour les formulaires. Les launchers antérieurs à la 3.3 ignorent la maintenance.
4. **Permission et trousseau** :
1. page **Groupes et permissions** : donne `maintenance.manage` (« Mode maintenance ») au groupe qui doit pouvoir
l'activer (le groupe Propriétaire l'a d'office ; Administrateur l'a sur une installation neuve) ;
2. signe un nouveau trousseau (étape 3 quinquies, point 3) : l'autorisation **« mode maintenance »** des appareils
concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
5. **Activer** depuis la page « Maintenance » (message facultatif, confirmation), **désactiver** de même. Les launchers
relisent le fichier au démarrage (avant toute mise à jour automatique), puis toutes les 2 minutes (toutes les minutes
pendant une maintenance) ; celui de l'administrateur qui publie bascule aussitôt. Un launcher resté ouvert pendant
la re-signature du trousseau (point 4) prend la nouvelle autorisation en compte à la relecture suivante
(launcher 3.3.1 et suivants ; en 3.3.0, il fallait le redémarrer).
Comportement des launchers :
- **Hors ligne** ou fichier injoignable au démarrage : pas de maintenance (le jeu installé reste jouable hors ligne).
Pendant que le launcher tourne, une coupure garde le dernier état vérifié.
- Fichier non signé, mal signé, hors limites ou plus ancien que le dernier vu (`contentSigning` « require ») : ignoré.
En « warn », il est appliqué et signalé dans le journal ; en « off », il est appliqué sans vérification.
- Une opération déjà commencée (téléchargement…) se termine ; un jeu déjà lancé n'est pas fermé. Les mises à jour du
**launcher lui-même** (signées par la clé racine) continuent : c'est le moyen de corriger un launcher défectueux.
- C'est un **confort d'affichage**, pas une barrière de sécurité : un launcher modifié peut l'ignorer. Si les serveurs
du jeu doivent refuser les connexions, c'est à eux de le faire.
Historique des 20 dernières publications : `maintenance-history/` dans les données privées. Audit : `maintenance.enable`,
`maintenance.disable`, `maintenance.update` (message modifié pendant la maintenance).
## 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 :
@@ -584,11 +631,14 @@ recréer les administrateurs.
│ et leurs captures <id>-<n>.png|jpg (sans métadonnées)
├─ feedback-forms.json (+ .bak) formulaires des demandes publiés (référence de la révision)
├─ feedback-forms-history/ 20 dernières révisions publiées
├─ maintenance.json (+ .bak) mode maintenance publié (référence de la révision)
├─ maintenance-history/ 20 dernières révisions publiées
└─ 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)
├─ keyring.json trousseau publié (écrit par le service, 644)
├─ feedback-forms.json formulaires des demandes signés (écrit par le service, 644)
├─ maintenance.json mode maintenance signé (é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é)
+11 -2
View File
@@ -1,6 +1,6 @@
# =============================================================================
# Catalogue des jeux, trousseau et formulaires des demandes (écrits par la Launcher API) — voir LISEZMOI.md,
# étapes 3 bis, 3 quinquies et 3 nonies :
# Catalogue des jeux, trousseau, formulaires des demandes et mode maintenance (écrits par la Launcher API) —
# voir LISEZMOI.md, étapes 3 bis, 3 quinquies, 3 nonies et 3 decies :
#
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
# AVANT « location / { … } » : (le bloc ci-dessous)
@@ -36,3 +36,12 @@ location = /catalog/feedback-forms.json {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Mode maintenance (launcher 3.3) : activé depuis le menu, signé (autorisation « maintenance » du trousseau), relu par les
# launchers au démarrage puis toutes les 1 à 2 minutes. Jamais mis en cache : la fin de la maintenance doit être vue tout de suite.
location = /catalog/maintenance.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
@@ -7,6 +7,7 @@ using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
@@ -315,6 +316,37 @@ public sealed class AdminSession : IAsyncDisposable
new FeedbackFormsPublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
}
/// <summary>Mode maintenance en vigueur (révision 0 : jamais publié, inactif).</summary>
public Task<MaintenanceState> GetMaintenanceAsync(CancellationToken ct = default) =>
GetAsync<MaintenanceState>(ApiRoutes.Maintenance, ct);
/// <summary>
/// Active, désactive ou modifie le mode maintenance : révision <paramref name="baseRevision"/> + 1, signé par la clé de
/// cette session (autorisation « maintenance » du trousseau requise chez les joueurs), puis envoyé en requête signée.
/// </summary>
/// <param name="message">Précision affichée aux joueurs, déjà nettoyée (<see cref="MaintenanceRules.CleanMessage"/>) ; ignorée à la désactivation.</param>
/// <param name="since">
/// Début de la maintenance : celui de la maintenance en cours pour un nouveau message, null pour en commencer une
/// (l'heure du serveur, à la seconde) ; ignoré à la désactivation.
/// </param>
public async Task<MaintenanceState> PublishMaintenanceAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision,
CancellationToken ct = default)
{
var document = new MaintenanceDocument
{
Revision = baseRevision + 1,
Enabled = enabled,
Message = enabled ? message : null,
Since = enabled ? since ?? ContentSigning.Truncate(DateTimeOffset.UtcNow + _clockOffset) : null
};
var errors = new List<string>();
if (!MaintenanceRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(message));
var signature = await SignContentAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), ct)
.ConfigureAwait(false);
return await SendSignedAsync<MaintenanceState>(HttpMethod.Put, ApiRoutes.Maintenance,
new MaintenancePublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
}
/// <summary>Contenu d'une capture d'écran (à revérifier avec FeedbackImages.Sanitize avant de l'afficher).</summary>
public Task<byte[]> GetFeedbackAttachmentAsync(string gameId, string id, int index, CancellationToken ct = default) =>
SendCoreAsync<byte[]>(HttpMethod.Get, ApiRoutes.GameFeedbackAttachment(CheckGameId(gameId), CheckFeedbackId(id), index), null,
+4
View File
@@ -147,6 +147,10 @@ public static class ApiRoutes
/// <summary>Contenu d'une capture d'écran (feedback.read).</summary>
public static string GameFeedbackAttachment(string gameId, string id, int index) => $"v1/games/{gameId}/feedback/{id}/attachments/{index}";
// ------------------------------------------------------------------ Mode maintenance du launcher
/// <summary>Mode maintenance : lecture (tout administrateur connecté), publication signée (PUT, maintenance.manage).</summary>
public const string Maintenance = "v1/maintenance";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
@@ -0,0 +1,202 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Contracts.Maintenance;
/// <summary>
/// Mode maintenance du launcher (maintenance.json, commun à tous les jeux), activé ou désactivé depuis le menu
/// d'administration. Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée
/// « maintenance » dans le trousseau : les launchers des joueurs le vérifient comme le catalogue avant d'en tenir compte.
/// Maintenance active : le launcher passe au rouge / orange, affiche le message au centre et bloque tout, sauf la
/// désinstallation des jeux et le menu d'administration.
/// Révision 0 = rien n'a jamais été publié (maintenance inactive).
/// </summary>
public sealed record MaintenanceDocument
{
public const string FormatName = "gamelauncher-maintenance";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Révision : +1 à chaque publication (couverte par la signature).</summary>
public long Revision { get; init; }
/// <summary>Vrai : maintenance en cours.</summary>
public bool Enabled { get; init; }
/// <summary>
/// Précision facultative affichée aux joueurs sous le texte principal (« Mise à jour des serveurs, retour vers 18 h »).
/// Texte brut, sans lien, quelques lignes au plus (<see cref="MaintenanceRules"/>).
/// </summary>
public string? Message { get; init; }
/// <summary>Début de la maintenance (à la seconde, UTC) ; null quand elle est désactivée. Couvert par la signature.</summary>
public DateTimeOffset? Since { get; init; }
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
public DateTimeOffset? UpdatedAt { get; init; }
public ContentSignature? Signature { get; init; }
}
/// <summary>État du mode maintenance pour le menu d'administration (GET v1/maintenance).</summary>
public sealed record MaintenanceState
{
/// <summary>Document en vigueur (révision 0, maintenance inactive, si rien n'a été publié).</summary>
public MaintenanceDocument Document { get; init; } = new();
/// <summary>Publication possible (fichier public configuré sur le serveur).</summary>
public bool PublishingEnabled { get; init; }
/// <summary>Adresse publique du fichier (lue par les launchers).</summary>
public string? PublicUrl { get; init; }
/// <summary>Administrateur et appareil de la clé qui a signé la dernière publication, si le serveur la connaît.</summary>
public string? UpdatedBy { get; init; }
}
/// <summary>Publication du mode maintenance (PUT v1/maintenance, requête signée, maintenance.manage).</summary>
public sealed record MaintenancePublishRequest
{
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
public long BaseRevision { get; init; }
/// <summary>Document signé, révision <see cref="BaseRevision"/> + 1.</summary>
public MaintenanceDocument Document { get; init; } = new();
}
/// <summary>
/// Règles du mode maintenance, communes au menu (contrôle avant de signer), au serveur (refus avant publication) et au
/// launcher des joueurs (refus d'un fichier hors limites, même signé). Le message est du texte brut, déjà nettoyé
/// (<see cref="CleanMessage"/>) et SANS lien : rien ne doit pouvoir renvoyer le joueur vers un site depuis ce bandeau.
/// </summary>
public static class MaintenanceRules
{
/// <summary>Taille de maintenance.json (et de la requête de publication, sous la limite de 64 Ko de l'API).</summary>
public const int MaxBytes = 8 * 1024;
public const int MaxMessageLength = 300;
public const int MaxMessageLines = 6;
/// <summary>Texte principal affiché aux joueurs (non modifiable : toujours présent, même sans message).</summary>
public const string Title = "Maintenance en cours";
public const string Subtitle = "Merci de revenir plus tard.";
/// <summary>Écart toléré entre le début annoncé et l'heure du serveur (horloges des PC).</summary>
public static readonly TimeSpan MaxSinceSkew = TimeSpan.FromMinutes(10);
private static readonly JsonSerializerOptions FileOptions = new(ApiJson.Options) { WriteIndented = true };
private static readonly JsonSerializerOptions CanonicalOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>Rien de publié (révision 0) : maintenance inactive.</summary>
public static MaintenanceDocument Defaults() => new() { Revision = 0, Enabled = false };
/// <summary>Lit maintenance.json (null : vide).</summary>
/// <exception cref="JsonException">JSON illisible ou trop volumineux.</exception>
public static MaintenanceDocument? Parse(ReadOnlySpan<byte> json)
{
if (json.Length > MaxBytes) throw new JsonException($"fichier de maintenance trop volumineux (au plus {MaxBytes / 1024} Ko)");
if (json.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF })) json = json[3..];
return JsonSerializer.Deserialize<MaintenanceDocument>(json, ApiJson.Options);
}
public static string Serialize(MaintenanceDocument document) => JsonSerializer.Serialize(document, FileOptions);
/// <summary>
/// Condensé signé : format, version, révision, état, message et début (à la seconde). La date de publication et la
/// signature n'en font pas partie.
/// </summary>
public static string Digest(MaintenanceDocument document)
{
var canonical = new CanonicalMaintenance(document.Format ?? "", document.Version, document.Revision, document.Enabled,
string.IsNullOrEmpty(document.Message) ? null : document.Message,
document.Since is { } since ? since.ToUnixTimeSeconds() : null);
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
}
private sealed record CanonicalMaintenance(string Format, int Version, long Revision, bool Enabled, string? Message, long? Since);
/// <summary>
/// Nettoie le message saisi par l'administrateur (fins de ligne, espaces, lignes vides ; mêmes refus que les demandes
/// des joueurs : caractères invisibles, de mise en forme ou d'inversion du sens d'écriture), puis vérifie les bornes et
/// l'absence de lien. Null si vide ; <paramref name="problem"/> : raison du refus.
/// </summary>
public static string? CleanMessage(string? value, out string? problem)
{
var text = FeedbackRules.CleanMultiline(value, MaxMessageLength, out problem);
if (problem is not null) return null;
if (text is null) return null;
if (text.Count(c => c == '\n') + 1 > MaxMessageLines)
{
problem = $"{MaxMessageLines} lignes au plus.";
return null;
}
if (FeedbackLinks.Contains(text))
{
problem = "aucun lien ni adresse de site n'est accepté (les joueurs ne doivent pas être renvoyés vers un site depuis ce message).";
return null;
}
return text;
}
/// <summary>
/// Contrôle d'un document publié (révision 1 au moins). Les erreurs, en français, sont ajoutées à <paramref name="errors"/>.
/// </summary>
/// <param name="now">Heure de référence (serveur) : le début ne peut pas être dans le futur ; null : non contrôlé (launcher).</param>
public static bool Validate(MaintenanceDocument? document, ICollection<string> errors, DateTimeOffset? now = null)
{
var count = errors.Count;
if (document is null)
{
errors.Add("Document de maintenance absent.");
return false;
}
if (document.Format != MaintenanceDocument.FormatName || document.Version != MaintenanceDocument.CurrentVersion)
errors.Add($"Format du mode maintenance non pris en charge ({document.Format} v{document.Version}).");
if (document.Revision < 1) errors.Add("Révision invalide (1 au moins).");
if (document.Message is { } message)
{
var clean = CleanMessage(message, out var problem);
if (problem is not null) errors.Add($"Message : {problem}");
else if (!string.Equals(clean, message, StringComparison.Ordinal)) errors.Add("Message : texte non nettoyé (espaces, lignes vides).");
}
if (document.Enabled)
{
if (document.Since is not { } since || since == default) errors.Add("Début de la maintenance absent.");
else if (since.UtcTicks % TimeSpan.TicksPerSecond != 0)
errors.Add("Début de la maintenance : à la seconde près.");
else if (now is { } reference && since > reference + MaxSinceSkew)
errors.Add("Début de la maintenance dans le futur : vérifiez l'heure de Windows.");
}
else
{
if (document.Since is not null) errors.Add("Maintenance désactivée : pas de date de début.");
if (document.Message is not null) errors.Add("Maintenance désactivée : pas de message.");
}
return errors.Count == count;
}
/// <summary>Résumé pour l'audit et les journaux (« activée — message : … », « désactivée »).</summary>
public static string Describe(MaintenanceDocument document) =>
document.Enabled
? "activée" + (document.Message is { } message ? $" — message : « {OneLine(message)} »" : " (sans message)")
: "désactivée";
private static string OneLine(string text)
{
var line = text.Replace('\n', ' ');
return line.Length <= 120 ? line : line[..119] + "…";
}
}
@@ -37,9 +37,13 @@ public static class AdminPermissions
/// <summary>Formulaires des demandes (champs proposés aux joueurs) : modification et publication signée.</summary>
public const string FeedbackForms = "feedback.forms";
/// <summary>Mode maintenance du launcher : activation, désactivation et message affiché aux joueurs (publication signée).</summary>
public const string MaintenanceManage = "maintenance.manage";
public static readonly IReadOnlyList<string> All = new[]
{
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms,
MaintenanceManage
};
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
@@ -57,6 +61,7 @@ public static class AdminPermissions
FeedbackRead => "Demandes des joueurs (lecture)",
FeedbackManage => "Demandes des joueurs (traitement)",
FeedbackForms => "Formulaires des demandes",
MaintenanceManage => "Mode maintenance",
_ => null
};
@@ -76,6 +81,9 @@ public static class AdminPermissions
"(à donner avec la lecture).",
FeedbackForms => "Modifier, masquer et publier les formulaires du bouton « Aide » (champs proposés aux joueurs). " +
"Publication signée : la clé doit avoir l'autorisation « formulaires » du trousseau.",
MaintenanceManage => "Activer ou désactiver le mode maintenance du launcher (tout est bloqué chez les joueurs, sauf la " +
"désinstallation des jeux) et modifier son message. Publication signée : la clé doit avoir " +
"l'autorisation « mode maintenance » du trousseau.",
_ => null
};
}
@@ -27,7 +27,8 @@ public static class AdminRoles
{
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage,
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms,
AdminPermissions.MaintenanceManage
},
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
};
@@ -29,6 +29,7 @@ public static class ContentKinds
public const string Manifest = "manifest";
public const string Catalog = "catalog";
public const string FeedbackForms = "feedback-forms";
public const string Maintenance = "maintenance";
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
public const string CatalogId = "catalog";
@@ -36,11 +37,15 @@ public static class ContentKinds
/// <summary>Identifiant signé des formulaires des demandes (communs à tous les jeux).</summary>
public const string FeedbackFormsId = "feedback-forms";
/// <summary>Identifiant signé du mode maintenance (commun à tous les jeux).</summary>
public const string MaintenanceId = "maintenance";
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
public static string ScopeOf(string kind) => kind switch
{
Manifest => SigningScopes.Releases,
FeedbackForms => SigningScopes.Forms,
Maintenance => SigningScopes.Maintenance,
_ => SigningScopes.Catalog
};
}
@@ -57,7 +62,10 @@ public static class SigningScopes
/// <summary>Signer les formulaires des demandes (champs affichés aux joueurs) : permission feedback.forms.</summary>
public const string Forms = "forms";
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms };
/// <summary>Signer le mode maintenance (activation, message affiché aux joueurs) : permission maintenance.manage.</summary>
public const string Maintenance = "maintenance";
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms, Maintenance };
/// <summary>
/// Autorisation bien formée : une autorisation inconnue de cette version (ajoutée par une version plus récente) est
@@ -74,6 +82,7 @@ public static class SigningScopes
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
if (set.Contains(AdminPermissions.FeedbackForms)) scopes.Add(Forms);
if (set.Contains(AdminPermissions.MaintenanceManage)) scopes.Add(Maintenance);
return scopes;
}
@@ -82,6 +91,7 @@ public static class SigningScopes
Releases => "versions du jeu",
Catalog => "catalogue",
Forms => "formulaires des demandes",
Maintenance => "mode maintenance",
_ => scope
};
}
@@ -219,6 +229,7 @@ public static class ContentSigning
{
ContentKinds.Catalog => ContentKinds.CatalogId,
ContentKinds.FeedbackForms => ContentKinds.FeedbackFormsId,
ContentKinds.Maintenance => ContentKinds.MaintenanceId,
_ => id.ToLowerInvariant()
};
@@ -58,6 +58,9 @@ public sealed record SigningState
/// <summary>Formulaires des demandes (null : publication des formulaires non configurée, ou serveur antérieur à la 3.2.1).</summary>
public FeedbackFormsSignatureState? FeedbackForms { get; init; }
/// <summary>Mode maintenance (null : publication non configurée, ou serveur antérieur à la 3.3.0).</summary>
public MaintenanceSignatureState? Maintenance { get; init; }
}
public sealed record CatalogSignatureState
@@ -83,3 +86,22 @@ public sealed record FeedbackFormsSignatureState
/// <summary>Vrai : le fichier de référence des formulaires est illisible sur le serveur.</summary>
public bool Unreadable { get; init; }
}
/// <summary>Signature du mode maintenance en vigueur (maintenance.json).</summary>
public sealed record MaintenanceSignatureState
{
/// <summary>Révision en vigueur (0 : jamais publié, maintenance inactive).</summary>
public long Revision { get; init; }
/// <summary>Maintenance en cours.</summary>
public bool Enabled { get; init; }
/// <summary>Condensé du contenu signé (vide : jamais publié, ou fichier illisible).</summary>
public string Digest { get; init; } = "";
public ContentSignature? Signature { get; init; }
public string? SignerName { get; init; }
/// <summary>Vrai : le fichier de référence du mode maintenance est illisible sur le serveur.</summary>
public bool Unreadable { get; init; }
}
@@ -0,0 +1,206 @@
using System.Net;
using System.Text.Json;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
namespace GameLauncher.Core.Maintenance;
/// <summary>
/// Mode maintenance publié depuis le menu d'administration (maintenance.json, à côté du trousseau), lu par le launcher
/// des joueurs :
/// • au démarrage, AVANT la recherche des mises à jour des jeux (le launcher l'attend quelques secondes au plus) ;
/// • puis toutes les <see cref="NormalInterval"/> (<see cref="ActiveInterval"/> pendant une maintenance, pour en voir la
/// fin rapidement) et à la demande (publication depuis le menu d'administration de ce PC) ;
/// • fichier borné en taille, contrôlé avec les règles du mode maintenance (même signé, un fichier hors limites est
/// refusé) ; signature vérifiée avec le trousseau (autorisation « maintenance »), retour en arrière refusé (révision
/// et date de signature), selon le mode « contentSigning » du launcher, comme le catalogue.
/// Rien n'est conservé sur le disque (hormis la date de la dernière signature acceptée, dans l'état des signatures) :
/// au démarrage, un serveur injoignable ne bloque jamais le launcher (le jeu installé reste jouable hors ligne). Pendant
/// que le launcher tourne, une coupure ou un fichier refusé garde le dernier état vérifié ; un fichier absent (404)
/// termine la maintenance.
/// Ce n'est pas une barrière de sécurité (un launcher modifié peut l'ignorer) : c'est un affichage pour les joueurs.
/// </summary>
public sealed class MaintenanceService
{
public static readonly TimeSpan NormalInterval = TimeSpan.FromMinutes(2);
public static readonly TimeSpan ActiveInterval = TimeSpan.FromMinutes(1);
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(8);
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
private readonly ContentTrustService? _trust;
private readonly TimeProvider _time;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly object _stateLock = new();
private byte[]? _lastAccepted;
private bool _unreachableLogged;
private MaintenanceDocument? _current;
/// <param name="url">Adresse du fichier publié (null : jamais de maintenance).</param>
/// <param name="trust">Vérification des signatures (null : mode « off », document utilisé sans signature).</param>
/// <param name="time">Horloge (tests).</param>
public MaintenanceService(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? url, ContentTrustService? trust,
TimeProvider? time = null)
{
_download = download;
Url = url;
_trust = trust;
_time = time ?? TimeProvider.System;
}
public Uri? Url { get; }
/// <summary>Dernier état accepté (null : rien de connu, ou rien de publié).</summary>
public MaintenanceDocument? Current
{
get
{
lock (_stateLock) return _current;
}
}
/// <summary>Maintenance en cours (dernier état vérifié).</summary>
public bool IsActive => Current is { Enabled: true };
/// <summary>
/// L'état affiché change (début, fin, nouveau message). Levé hors du fil de l'interface : l'appelant s'y ramène.
/// Null : plus de maintenance (fichier absent).
/// </summary>
public event Action<MaintenanceDocument?>? Changed;
/// <summary>Adresse du fichier publié, déduite de celle du trousseau (ou, à défaut, du catalogue) : même dossier.</summary>
public static Uri? PublishedUrl(string? keyringUrl, string? catalogUrl)
{
var baseUrl = keyringUrl ?? catalogUrl;
return baseUrl is not null && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) ? new Uri(uri, "maintenance.json") : null;
}
/// <summary>Relit le fichier publié. Jamais d'exception (sauf annulation demandée par l'appelant).</summary>
/// <returns>Vrai : maintenance en cours (dernier état vérifié).</returns>
public async Task<bool> RefreshAsync(CancellationToken ct = default)
{
if (Url is null) return false;
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
byte[] bytes;
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(FetchTimeout);
bytes = await _download(Url, MaintenanceRules.MaxBytes, timeout.Token).ConfigureAwait(false);
_unreachableLogged = false;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Rien de publié (ou fichier retiré à la main) : pas de maintenance.
_unreachableLogged = false;
_lastAccepted = null;
Apply(null);
return false;
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
// Réseau, délai dépassé, réponse trop grande : dernier état vérifié conservé, nouvel essai plus tard.
if (!_unreachableLogged)
Log.Warn($"Mode maintenance injoignable : {ex.Message}" + (_current is { Enabled: true } ? " (maintenance toujours affichée)." : "."));
_unreachableLogged = true;
return _current is { Enabled: true };
}
// Même fichier que la dernière fois : déjà vérifié.
if (_lastAccepted is not null && bytes.AsSpan().SequenceEqual(_lastAccepted)) return _current is { Enabled: true };
var document = await AcceptAsync(bytes, ct).ConfigureAwait(false);
if (document is null) return _current is { Enabled: true };
_lastAccepted = bytes;
Apply(document);
return document.Enabled;
}
finally
{
_gate.Release();
}
}
/// <summary>Relecture périodique jusqu'à la fermeture du launcher (rythme plus rapide pendant une maintenance).</summary>
public async Task RunAsync(CancellationToken lifetime)
{
if (Url is null) return;
try
{
while (!lifetime.IsCancellationRequested)
{
await Task.Delay(IsActive ? ActiveInterval : NormalInterval, _time, lifetime).ConfigureAwait(false);
await RefreshAsync(lifetime).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (lifetime.IsCancellationRequested)
{
// fermeture du launcher
}
catch (Exception ex)
{
Log.Error("Relecture du mode maintenance interrompue", ex);
}
}
/// <summary>Fichier lisible, dans les règles, pas plus ancien que l'état connu, à la signature acceptée ; sinon null.</summary>
private async Task<MaintenanceDocument?> AcceptAsync(byte[] bytes, CancellationToken ct)
{
MaintenanceDocument? document;
try
{
document = MaintenanceRules.Parse(bytes);
}
catch (JsonException ex)
{
Log.Error($"Mode maintenance illisible ({ex.Message}) : ignoré.");
return null;
}
var errors = new List<string>();
if (!MaintenanceRules.Validate(document, errors))
{
Log.Error($"Mode maintenance refusé : {errors[0]}");
return null;
}
if (_current is { } current && document!.Revision < current.Revision)
{
Log.Error($"Mode maintenance révision {document.Revision} refusé : plus ancien que la révision {current.Revision} déjà vue (retour en arrière).");
return null;
}
if (_trust is not null && !await _trust.VerifyMaintenanceAsync(document!, ct).ConfigureAwait(false)) return null;
return document;
}
private void Apply(MaintenanceDocument? document)
{
MaintenanceDocument? previous;
lock (_stateLock)
{
previous = _current;
_current = document;
}
if (SameDisplay(previous, document)) return;
Log.Info(document is { Enabled: true }
? $"Mode maintenance ACTIVÉ (révision {document.Revision}) : tout est bloqué sauf la désinstallation et l'administration."
: previous is { Enabled: true } ? "Mode maintenance terminé." : $"Mode maintenance : inactif (révision {document?.Revision ?? 0}).");
try
{
Changed?.Invoke(document);
}
catch (Exception ex)
{
Log.Error("Application du mode maintenance", ex);
}
}
/// <summary>Même affichage pour le joueur (état, message, début) : rien à signaler.</summary>
private static bool SameDisplay(MaintenanceDocument? a, MaintenanceDocument? b)
{
var activeA = a is { Enabled: true };
var activeB = b is { Enabled: true };
if (!activeA && !activeB) return true;
return activeA == activeB && a!.Message == b!.Message && a.Since == b.Since;
}
}
@@ -2,6 +2,7 @@ using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
@@ -40,8 +41,23 @@ public sealed class ContentSecurityException : Exception
public sealed class ContentTrustService
{
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
/// <summary>Nouvel essai après un échec réseau (trousseau jamais obtenu pendant ce démarrage).</summary>
public static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
/// <summary>
/// Nouveau téléchargement, au plus à ce rythme, quand un contenu est signé par une clé absente du trousseau ou non
/// autorisée pour ce contenu : clé ajoutée ou autorisation donnée (ex. « maintenance ») pendant que le launcher tourne.
/// Plus court que la relecture du mode maintenance, pour qu'une activation juste après la republication du trousseau
/// soit vue à la relecture suivante.
/// </summary>
public static readonly TimeSpan KeyChangeRetryDelay = TimeSpan.FromMinutes(1);
/// <summary>
/// Âge maximal du trousseau en mémoire : au-delà, il est retéléchargé à la vérification suivante (une clé retirée ou
/// une autorisation enlevée est prise en compte sans redémarrer le launcher).
/// </summary>
public static readonly TimeSpan KeyringMaxAge = TimeSpan.FromMinutes(30);
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
@@ -81,12 +97,15 @@ public sealed class ContentTrustService
/// <summary>
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
/// vu (cache), sinon null. Téléchargé au démarrage, puis de nouveau :
/// • après un échec réseau (<see cref="RetryDelay"/>) ;
/// • quand un contenu est signé par une clé absente ou non autorisée pour ce contenu (<see cref="KeyChangeRetryDelay"/>) ;
/// • quand il a plus de <see cref="KeyringMaxAge"/> (launcher ouvert longtemps).
/// Chaque nouveau trousseau passe par les mêmes contrôles (signature racine, numéro jamais en baisse).
/// </summary>
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
private async Task<KeyringDocument?> GetKeyringAsync(bool keyChanged, CancellationToken ct)
{
Task<KeyringDocument?> task;
lock (_lock)
@@ -94,7 +113,8 @@ public sealed class ContentTrustService
var elapsed = _time.GetUtcNow() - _lastAttempt;
if (_keyringTask is null
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
|| (_keyringTask.IsCompleted && keyChanged && elapsed > KeyChangeRetryDelay)
|| (_keyringTask.IsCompleted && elapsed > KeyringMaxAge))
{
_lastAttempt = _time.GetUtcNow();
_keyringTask = FetchKeyringAsync();
@@ -150,7 +170,7 @@ public sealed class ContentTrustService
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
{
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
lock (_lock) _keyringFetched = true; // contenu refusé : nouvel essai seulement aux conditions de GetKeyringAsync
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
return cached;
}
@@ -210,6 +230,26 @@ public sealed class ContentTrustService
}
}
/// <summary>
/// Vérifie le mode maintenance téléchargé (maintenance.json) : clé du trousseau autorisée « maintenance », signature
/// valide, pas plus ancien que le dernier accepté (un serveur compromis ne peut pas resservir une ancienne maintenance
/// légitime). Faux : à ignorer (état précédent conservé).
/// </summary>
public async Task<bool> VerifyMaintenanceAsync(MaintenanceDocument maintenance, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return true;
try
{
await VerifyAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(maintenance), maintenance.Signature,
$"Mode maintenance (révision {maintenance.Revision}, {MaintenanceRules.Describe(maintenance)})", ct).ConfigureAwait(false);
return true;
}
catch (ContentSecurityException)
{
return false;
}
}
/// <summary>Revérifie les formulaires en cache, sans réseau, avec le dernier trousseau vérifié (fichier modifié sur le disque : ignoré).</summary>
public bool VerifyCachedFeedbackForms(FeedbackFormsDocument forms)
{
@@ -253,8 +293,9 @@ public sealed class ContentTrustService
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
{
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
// Clé absente du trousseau, ou présente sans l'autorisation de ce contenu (clé ajoutée, ou autorisation donnée,
// depuis le dernier téléchargement) : nouveau téléchargement du trousseau, limité dans le temps.
if (keyring is not null && KeyMayBeNewer(keyring, kind, signature))
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
string problem;
@@ -301,6 +342,14 @@ public sealed class ContentTrustService
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
}
/// <summary>La signature vient d'une clé que ce trousseau ne connaît pas, ou n'autorise pas pour ce contenu.</summary>
private static bool KeyMayBeNewer(KeyringDocument keyring, string kind, ContentSignature? signature)
{
if (signature?.KeyId is not { } keyId) return false;
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
return key?.Scopes is not { } scopes || !scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal);
}
// ------------------------------------------------------------------ État
private sealed class TrustState
@@ -58,6 +58,7 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
new("files.", "Fichiers du serveur"),
new("keyring.", "Signature (trousseau)"),
new("feedback.", "Demandes des joueurs"),
new("maintenance.", "Mode maintenance"),
new("audit.", "Journal d'audit (consultations, exports, purges)"),
new("security", "Refus de sécurité")
};
@@ -504,6 +505,9 @@ public static class AuditTexts
"feedback.delete" => "Demande de joueur supprimée",
"feedback.purge" => "Anciennes demandes supprimées (conservation)",
"feedback.forms" => "Formulaires des demandes publiés",
"maintenance.enable" => "Mode maintenance activé",
"maintenance.disable" => "Mode maintenance terminé",
"maintenance.update" => "Message de la maintenance modifié",
"files.delete" => "Fichier du serveur supprimé",
"files.prune" => "Dossier « objects » nettoyé",
"request.signature" => "Requête signée refusée",
@@ -0,0 +1,26 @@
using GameLauncher.Contracts.Security;
namespace GameLauncher.Modules.Admin.Maintenance;
/// <summary>
/// Module « Maintenance » : page d'activation et de désactivation du mode maintenance du launcher (commun à tous les
/// jeux), avec un message facultatif pour les joueurs. Permission maintenance.manage ; publication signée (autorisation
/// « maintenance » du trousseau, vérifiée par les launchers). L'état est aussi rappelé au tableau de bord.
/// </summary>
public sealed class MaintenanceAdminModule : ILauncherModule
{
public string Id => "maintenance";
public string DisplayName => "Maintenance";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
MaintenancePanelViewModel.PanelId,
"Maintenance",
"Mode maintenance de tous les launchers : thème rouge / orange, message au centre, tout est bloqué chez les joueurs " +
"sauf la désinstallation des jeux et ce menu.",
"TrafficCone",
AdminPermissions.MaintenanceManage,
Order: 5,
context => new MaintenancePanelViewModel(context)));
}
@@ -0,0 +1,250 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Signing;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Maintenance;
/// <summary>
/// Page « Maintenance » (maintenance.manage) : état du mode maintenance de tous les launchers, activation (message
/// facultatif, confirmation), modification du message pendant la maintenance, désactivation (confirmation).
/// • Chaque publication est signée par la clé de ce PC (autorisation « maintenance » du trousseau) : avertissement si
/// les launchers des joueurs ne l'accepteront pas (<see cref="AdminPanelViewModel.ConfirmPlayerSigningAsync"/>).
/// • Message contrôlé ici avec les règles partagées (le serveur et les launchers les réappliquent) : texte brut, sans lien.
/// • Après une publication, le launcher de ce PC relit aussitôt le mode maintenance (<see cref="IAdminContext.NotifyMaintenanceChanged"/>).
/// </summary>
public sealed class MaintenancePanelViewModel : AdminPanelViewModel
{
public const string PanelId = "maintenance";
private MaintenanceState? _state;
private string _message = "";
private string? _messageError;
private string? _statusMessage;
public MaintenancePanelViewModel(IAdminContext context) : base(context)
{
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
EnableCommand = new AsyncRelayCommand(ScrollingToTop(EnableAsync), () => !IsBusy && CanPublish && !IsActive);
UpdateMessageCommand = new AsyncRelayCommand(ScrollingToTop(UpdateMessageAsync), () => !IsBusy && CanPublish && IsActive && IsMessageChanged);
DisableCommand = new AsyncRelayCommand(ScrollingToTop(DisableAsync), () => !IsBusy && CanPublish && IsActive);
}
public AsyncRelayCommand RefreshCommand { get; }
public AsyncRelayCommand EnableCommand { get; }
public AsyncRelayCommand UpdateMessageCommand { get; }
public AsyncRelayCommand DisableCommand { get; }
// ------------------------------------------------------------------ État publié
public bool IsLoaded => _state is not null;
/// <summary>Maintenance en cours (état publié).</summary>
public bool IsActive => _state?.Document.Enabled == true;
/// <summary>Publication possible : état chargé et fichier public configuré sur le serveur.</summary>
public bool CanPublish => _state is { PublishingEnabled: true };
public bool ShowPublishingDisabled => _state is { PublishingEnabled: false };
public string? PublicUrl => _state?.PublicUrl;
public string StateTitle => _state is null ? "Chargement…" : IsActive ? "Maintenance en cours" : "Launchers accessibles";
public string StateText
{
get
{
if (_state is not { } state) return "";
var document = state.Document;
var by = state.UpdatedBy is { } name ? $" par {name}" : "";
if (document.Revision == 0) return "Le mode maintenance n'a jamais été activé.";
if (document.Enabled)
return (document.Since is { } since ? $"Depuis le {AccountTexts.Date(since)}" : "En cours") + by +
(document.UpdatedAt is { } updated && document.Since is { } start && updated - start > TimeSpan.FromMinutes(1)
? $" · message modifié le {AccountTexts.Date(updated)}"
: "") + ".";
return (document.UpdatedAt is { } at ? $"Dernière maintenance terminée le {AccountTexts.Date(at)}" : "Maintenance terminée") + by + ".";
}
}
/// <summary>Message publié (pendant la maintenance), sinon null.</summary>
public string? PublishedMessage => IsActive ? _state!.Document.Message : null;
// ------------------------------------------------------------------ Saisie
/// <summary>Précision affichée aux joueurs sous « Maintenance en cours — merci de revenir plus tard » (facultative).</summary>
public string Message
{
get => _message;
set
{
if (!SetProperty(ref _message, value ?? "")) return;
MaintenanceRules.CleanMessage(_message, out var problem);
MessageError = problem is null ? null : $"Message : {problem}";
OnPropertyChanged(nameof(MessageCounter));
OnPropertyChanged(nameof(PreviewMessage));
OnPropertyChanged(nameof(IsMessageChanged));
RefreshCommands();
}
}
public string? MessageError
{
get => _messageError;
private set
{
if (SetProperty(ref _messageError, value)) OnPropertyChanged(nameof(HasMessageError));
}
}
public bool HasMessageError => _messageError is not null;
public string MessageCounter => $"{_message.Trim().Length} / {MaintenanceRules.MaxMessageLength}";
public int MaxMessageLength => MaintenanceRules.MaxMessageLength;
/// <summary>Le message saisi diffère de celui publié (bouton « Modifier le message »).</summary>
public bool IsMessageChanged
{
get
{
var clean = MaintenanceRules.CleanMessage(_message, out var problem);
return problem is null && !string.Equals(clean, PublishedMessage, StringComparison.Ordinal);
}
}
// ------------------------------------------------------------------ Aperçu (textes affichés aux joueurs)
public string PreviewTitle => MaintenanceRules.Title;
public string PreviewSubtitle => MaintenanceRules.Subtitle;
/// <summary>Message tel que les joueurs le verront (nettoyé), sinon null.</summary>
public string? PreviewMessage => MaintenanceRules.CleanMessage(_message, out _);
// ------------------------------------------------------------------ Bandeau de résultat
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
// ------------------------------------------------------------------ Chargement
protected override void OnBusyChanged() => RefreshCommands();
private void RefreshCommands()
{
RefreshCommand.RaiseCanExecuteChanged();
EnableCommand.RaiseCanExecuteChanged();
UpdateMessageCommand.RaiseCanExecuteChanged();
DisableCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
private async Task ReloadAsync() => Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: true);
private void Apply(MaintenanceState state, bool resetMessage)
{
_state = state;
if (resetMessage) Message = state.Document.Enabled ? state.Document.Message ?? "" : _message;
foreach (var name in new[]
{
nameof(IsLoaded), nameof(IsActive), nameof(CanPublish), nameof(ShowPublishingDisabled), nameof(PublicUrl),
nameof(StateTitle), nameof(StateText), nameof(PublishedMessage), nameof(IsMessageChanged)
})
OnPropertyChanged(name);
RefreshCommands();
}
// ------------------------------------------------------------------ Actions
/// <summary>Message nettoyé, ou null avec l'erreur affichée (sous le champ).</summary>
private bool TryCleanMessage(out string? clean)
{
clean = MaintenanceRules.CleanMessage(_message, out var problem);
MessageError = problem is null ? null : $"Message : {problem}";
return problem is null;
}
private async Task EnableAsync()
{
if (_state is not { } state) return;
StatusMessage = null;
if (!TryCleanMessage(out var message)) return;
if (!Context.Dialogs.Confirm("Activer le mode maintenance ?",
"Tous les launchers des joueurs passeront en mode maintenance au plus tard dans 2 minutes (ceux qui démarrent : " +
"aussitôt), pour tous les jeux :\n\n" +
"• installation, mises à jour, vérification, réparation et lancement des jeux bloqués ;\n" +
"• paramètres du launcher et des jeux, bouton « Aide » et site officiel indisponibles ;\n" +
"• seule la désinstallation reste possible, et ce menu d'administration.\n\n" +
"Les téléchargements déjà commencés se terminent et les parties en cours ne sont pas fermées. Les mises à jour du " +
"launcher lui-même continuent.",
"Activer la maintenance", danger: true))
return;
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Activer quand même")) return;
await PublishAsync(enabled: true, message, since: null, state.Document.Revision,
"Mode maintenance activé : les launchers des joueurs l'affichent au plus tard dans 2 minutes.");
}
private async Task UpdateMessageAsync()
{
if (_state is not { Document: { Enabled: true } document }) return;
StatusMessage = null;
if (!TryCleanMessage(out var message)) return;
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Modifier quand même")) return;
await PublishAsync(enabled: true, message, document.Since, document.Revision,
"Message de la maintenance modifié : les launchers des joueurs l'affichent au plus tard dans une minute.");
}
private async Task DisableAsync()
{
if (_state is not { Document: { Enabled: true } document }) return;
StatusMessage = null;
if (!Context.Dialogs.Confirm("Terminer la maintenance ?",
"Les launchers des joueurs redeviendront utilisables au plus tard dans une minute (installation, mises à jour, " +
"lancement des jeux) ; ils rechercheront aussitôt les mises à jour des jeux.",
"Terminer la maintenance"))
return;
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Terminer quand même")) return;
await PublishAsync(enabled: false, message: null, since: null, document.Revision,
"Maintenance terminée : les launchers des joueurs redeviennent utilisables au plus tard dans une minute.");
}
private async Task PublishAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision, string success)
{
MaintenanceState? published = null;
var ok = await RunAsync(async () =>
published = await Context.Session.PublishMaintenanceAsync(enabled, message, since, baseRevision));
if (!ok)
{
// Publication par quelqu'un d'autre entre-temps (409) : état relu, la saisie est gardée.
if (ErrorMessage is not null) await ReloadKeepingMessageAsync();
return;
}
Apply(published!, resetMessage: true);
if (!enabled) Message = "";
StatusMessage = success;
Context.NotifyMaintenanceChanged(); // ce launcher bascule tout de suite
}
private async Task ReloadKeepingMessageAsync()
{
try
{
Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: false);
}
catch (Exception ex) when (ex is ApiException or HttpRequestException or TaskCanceledException)
{
// L'erreur de la publication reste affichée ; « Recharger » permet de réessayer.
}
}
}
@@ -10,7 +10,7 @@ namespace GameLauncher.Modules.Admin.Signing;
/// <summary>
/// Page « Signature » (propriétaires) : trousseau publié (vérifié avec la clé racine intégrée à l'exe), trousseau
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne (versions en
/// ligne, catalogue, formulaires des demandes).
/// ligne, catalogue, formulaires des demandes, mode maintenance).
/// La clé racine n'est JAMAIS ouverte par le launcher : la demande est exportée, signée hors ligne avec
/// tools\sign-keyring.bat, puis le trousseau signé est publié ici.
/// </summary>
@@ -204,6 +204,19 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, forms.Digest, forms.Signature, forms.SignerName, state.Keyring, proposed,
"Republiez-les depuis la page « Formulaires des demandes » (ils sont signés à chaque publication).",
"Formulaires d'origine, intégrés au launcher : rien à signer tant que rien n'est publié."));
if (state.Maintenance is { } maintenance)
items.Add(maintenance.Unreadable
? SignedContentItem.Failed("Mode maintenance",
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
"dans les données privées du serveur.")
: new SignedContentItem(
maintenance.Revision > 0
? $"Mode maintenance (révision {maintenance.Revision}, {(maintenance.Enabled ? "en cours" : "inactif")})"
: "Mode maintenance",
ContentKinds.Maintenance, ContentKinds.MaintenanceId, maintenance.Digest, maintenance.Signature, maintenance.SignerName,
state.Keyring, proposed,
"Republiez-le depuis la page « Maintenance » (il est signé à chaque activation, modification ou fin).",
"Jamais activé : rien à signer tant que rien n'est publié."));
return items;
}
@@ -47,6 +47,12 @@ public sealed class AdminEnvironment
/// </summary>
public PlayerSigningCheck? PlayerSigning { get; init; }
/// <summary>
/// Appelé après une publication du mode maintenance (page « Maintenance ») : le launcher de ce PC le relit aussitôt.
/// Null : rien à faire (tests).
/// </summary>
public Action? MaintenanceChanged { get; init; }
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
public IAdminTpm? Tpm { get; init; }
@@ -127,6 +127,8 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
public string LauncherVersion => _shell.Environment.LauncherVersion;
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
public PlayerSigningCheck? PlayerSigning => _shell.Environment.PlayerSigning;
public void NotifyMaintenanceChanged() => _shell.Environment.MaintenanceChanged?.Invoke();
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
@@ -2,6 +2,7 @@ using GameLauncher.Contracts.Security;
using GameLauncher.ApiClient;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Maintenance;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
@@ -34,6 +35,45 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
OpenFeedbackCommand = new RelayCommand(() => Context.Navigate(FeedbackPanelViewModel.PanelId), () => CanReadFeedback);
OpenMaintenanceCommand = new RelayCommand(() => Context.Navigate(MaintenancePanelViewModel.PanelId), () => CanOpenMaintenance);
}
// ------------------------------------------------------------------ Mode maintenance
/// <summary>Page « Maintenance » présente (permission maintenance.manage).</summary>
public bool CanOpenMaintenance => Context.Panels.Any(p => p.Id == MaintenancePanelViewModel.PanelId);
public RelayCommand OpenMaintenanceCommand { get; }
private string? _maintenanceText;
/// <summary>Bandeau « Maintenance en cours depuis … » (null : pas de maintenance, ou serveur antérieur à la 3.3.0).</summary>
public string? MaintenanceText
{
get => _maintenanceText;
private set
{
if (SetProperty(ref _maintenanceText, value)) OnPropertyChanged(nameof(IsMaintenanceActive));
}
}
public bool IsMaintenanceActive => _maintenanceText is not null;
private async Task RefreshMaintenanceAsync()
{
try
{
var state = await Context.Session.GetMaintenanceAsync();
MaintenanceText = state.Document is { Enabled: true } document
? "Maintenance en cours" + (document.Since is { } since ? $" depuis le {AccountTexts.Date(since)}" : "") +
(state.UpdatedBy is { } by ? $" (activée par {by})" : "") +
" : les joueurs ne peuvent ni installer, ni mettre à jour, ni lancer les jeux."
: null;
}
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
{
MaintenanceText = null; // serveur antérieur (404) ou fichier illisible : rien au tableau de bord
}
}
// ------------------------------------------------------------------ Demandes des joueurs
@@ -182,6 +222,7 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
ActiveInvitations = invitations.Count;
}
if (CanReadFeedback) await RefreshFeedbackAsync();
await RefreshMaintenanceAsync();
LastRefresh = $"Actualisé à {DateTime.Now:HH:mm}";
});
}
@@ -36,6 +36,12 @@ public interface IAdminContext
/// </summary>
PlayerSigningCheck? PlayerSigning => null;
/// <summary>
/// Le mode maintenance vient d'être publié depuis le menu : le launcher de ce PC le relit aussitôt (sans attendre sa
/// relecture périodique). Sans effet par défaut (tests).
/// </summary>
void NotifyMaintenanceChanged() { }
/// <summary>Pages visibles pour cet administrateur.</summary>
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
@@ -42,7 +42,8 @@ public sealed class PlayerSigningCheck
/// Texte de l'avertissement à afficher avant de publier un contenu de ce type signé par cette clé, ou null si les
/// launchers des joueurs l'accepteront (ou ne vérifient pas les signatures). Ne lève pas d'exception (sauf annulation).
/// </summary>
/// <param name="kind"><see cref="ContentKinds.Catalog"/>, <see cref="ContentKinds.Manifest"/> ou <see cref="ContentKinds.FeedbackForms"/>.</param>
/// <param name="kind"><see cref="ContentKinds.Catalog"/>, <see cref="ContentKinds.Manifest"/>, <see cref="ContentKinds.FeedbackForms"/>
/// ou <see cref="ContentKinds.Maintenance"/>.</param>
/// <param name="publicKey">Clé publique (SPKI) de ce PC : celle qui signera, lue localement.</param>
public async Task<string?> WarningAsync(string kind, byte[] publicKey, CancellationToken ct = default)
{
@@ -111,6 +112,9 @@ public sealed class PlayerSigningCheck
? kind == ContentKinds.FeedbackForms
? "Les launchers des joueurs (signatures obligatoires) ignoreront ces formulaires : la fenêtre « Aide » proposera " +
"les derniers formulaires acceptés, ou ceux d'origine."
: kind == ContentKinds.Maintenance
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce changement : ils garderont l'état du mode " +
"maintenance qu'ils connaissent (une maintenance ne s'afficherait pas, ou ne se terminerait qu'à leur redémarrage)."
: catalog
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce catalogue : ils garderont le précédent " +
"(celui intégré au launcher pour une installation neuve)."
+80 -3
View File
@@ -7,6 +7,7 @@ using System.Windows.Media;
using System.Windows.Threading;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Catalog;
using GameLauncher.Core.Maintenance;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Services;
@@ -20,6 +21,7 @@ using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Files;
using GameLauncher.Modules.Admin.Maintenance;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Platform;
@@ -64,6 +66,13 @@ public partial class App : Application, ILauncherRestartHost
private string? _keyringUrl;
private PlayerSigningCheck? _playerSigning;
private Task<CatalogDocument?>? _catalogFetch;
/// <summary>Mode maintenance publié depuis le menu d'administration (null : aucune adresse de trousseau ni de catalogue).</summary>
private MaintenanceService? _maintenance;
private Task<bool>? _maintenanceFetch;
/// <summary>Annulé à la fermeture du launcher (relecture périodique du mode maintenance).</summary>
private readonly CancellationTokenSource _lifetime = new();
private string _launcherPath = "";
private Version _version = new(1, 0, 0);
@@ -172,6 +181,11 @@ public partial class App : Application, ILauncherRestartHost
_trust = CreateContentTrust(catalog);
if (_trust is not null && args.CaptureDirectory is null && !args.Uninstall) _ = _trust.GetKeyringAsync();
// Mode maintenance (publié depuis le menu d'administration) : lu dès maintenant, en parallèle du catalogue et de la
// mise à jour du launcher, puis attendu quelques secondes au plus AVANT la recherche des mises à jour des jeux.
var maintenanceStarted = DateTime.UtcNow;
StartMaintenance(catalog, download: args.CaptureDirectory is null);
// Catalogue des jeux (informations modifiables publiées depuis le menu d'administration) :
// dernier catalogue en cache appliqué tout de suite ; version à jour téléchargée pendant la
// vérification de mise à jour du launcher (pas de téléchargement pour --capture et --uninstall).
@@ -222,7 +236,8 @@ public partial class App : Application, ILauncherRestartHost
else if (normalStart && _updates.IsEnabled && (!_updates.AutoUpdate || _updates.SkippedUpdate is not null))
_ = NotifyLauncherUpdateAsync();
// 5. Détection des installations + mises à jour des jeux
// 5. Détection des installations + mises à jour des jeux (après le mode maintenance, qui peut les bloquer)
if (args.CaptureDirectory is null) await ApplyStartupMaintenanceAsync(maintenanceStarted, wait: !args.Uninstall);
try
{
if (args.CaptureDirectory is not null)
@@ -359,12 +374,21 @@ public partial class App : Application, ILauncherRestartHost
launcher.SelectedGame = launcher.ResolveInitialGame(requestedGameId); // applique le thème du jeu
launcher.AttachTransition(window);
// Mode maintenance : chaque changement (relecture périodique, publication depuis le menu de ce PC), puis l'état déjà connu.
if (_maintenance is { } maintenance && !readOnly)
{
maintenance.Changed += state => Dispatcher.InvokeAsync(() => launcher.ApplyMaintenance(state));
launcher.ApplyMaintenance(maintenance.Current);
}
// Menu d'administration (accès discret : Ctrl+Maj+Alt+A ou 5 clics sur le logo). Pas en mode --capture.
if (!readOnly)
{
var (apiUrl, apiUrlError) = ResolveAdminApiUrl(catalog);
// Publication du mode maintenance depuis le menu : ce launcher le relit aussitôt (sans attendre la relecture périodique).
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
ThemeManager.BuiltInThemes.ToList(), _playerSigning);
ThemeManager.BuiltInThemes.ToList(), _playerSigning,
maintenanceChanged: () => { if (_maintenance is { } m) _ = m.RefreshAsync(_lifetime.Token); });
window.AdminRequested += _admin.Open;
// Bouton « Aide » (demandes des joueurs) : même adresse d'API, lue au même endroit (launcher.json intégré en Release).
@@ -374,7 +398,11 @@ public partial class App : Application, ILauncherRestartHost
: new GameLauncher.Modules.Feedback.FeedbackFormsService(_http.GetBytesAsync, formsUrl, catalog.DataDirectory, _trust);
var feedback = new FeedbackWindowService(window, apiUrl, version, forms);
launcher.IsHelpAvailable = feedback.IsAvailable;
window.HelpRequested += game => feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
window.HelpRequested += game =>
{
if (launcher.IsMaintenance) return; // bouton masqué pendant une maintenance
feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
};
}
window.DataContext = launcher;
@@ -383,6 +411,53 @@ public partial class App : Application, ILauncherRestartHost
return window;
}
// ------------------------------------------------------------------ Mode maintenance
/// <summary>Attente maximale du mode maintenance au démarrage (au-delà : appliqué à son arrivée).</summary>
private static readonly TimeSpan MaintenanceStartupBudget = TimeSpan.FromSeconds(4);
/// <summary>
/// Lance la lecture du mode maintenance (maintenance.json, à côté du trousseau ou du catalogue). Rien n'est gardé sur le
/// disque : hors ligne, le launcher démarre normalement (le jeu installé reste jouable).
/// </summary>
private void StartMaintenance(LauncherCatalog catalog, bool download)
{
if (!download || _http is null) return;
try
{
var url = MaintenanceService.PublishedUrl(_keyringUrl, ResolveCatalogUrl(catalog));
if (url is null) return;
_maintenance = new MaintenanceService(_http.GetBytesAsync, url, _trust);
_maintenanceFetch = _maintenance.RefreshAsync(_lifetime.Token);
}
catch (Exception ex)
{
Log.Error("Mode maintenance", ex);
}
}
/// <summary>
/// Avant la recherche des mises à jour des jeux : attend le mode maintenance (quelques secondes au plus), l'applique,
/// puis lance sa relecture périodique. <paramref name="wait"/> faux (désinstallation depuis Windows) : pas d'attente.
/// </summary>
private async Task ApplyStartupMaintenanceAsync(DateTime started, bool wait)
{
if (_maintenance is not { } maintenance || _maintenanceFetch is not { } fetch) return;
try
{
var remaining = MaintenanceStartupBudget - (DateTime.UtcNow - started);
if (wait && !fetch.IsCompleted && remaining > TimeSpan.Zero) await Task.WhenAny(fetch, Task.Delay(remaining));
if (wait && !fetch.IsCompleted)
Log.Info("Mode maintenance : serveur lent, démarrage sans attendre (l'état sera appliqué à son arrivée).");
_launcher?.ApplyMaintenance(maintenance.Current);
}
catch (Exception ex)
{
Log.Error("Mode maintenance", ex);
}
_ = maintenance.RunAsync(_lifetime.Token);
}
// ------------------------------------------------------------------ Catalogue des jeux
/// <summary>Attente maximale du catalogue à jour au démarrage (au-delà : cache, mise à jour au démarrage suivant).</summary>
@@ -591,6 +666,7 @@ public partial class App : Application, ILauncherRestartHost
new ChangelogAdminModule(),
new PublishAdminModule(),
new FilesAdminModule(),
new MaintenanceAdminModule(),
new AccountsAdminModule(),
new SigningAdminModule(),
new AuditAdminModule()
@@ -676,6 +752,7 @@ public partial class App : Application, ILauncherRestartHost
protected override void OnExit(ExitEventArgs e)
{
_lifetime.Cancel(); // relecture du mode maintenance
_instanceChannel?.Dispose();
_instanceChannel = null;
_admin?.Dispose(); // ferme le menu d'administration : clé effacée de la mémoire
+1 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>3.2.1</Version>
<Version>3.3.1</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -28,6 +28,7 @@ public sealed class AdminWindowService : IDisposable
private readonly Uri? _apiUrl;
private readonly string? _apiUrlError;
private readonly PlayerSigningCheck? _playerSigning;
private readonly Action? _maintenanceChanged;
private LauncherApiClient? _client;
private AdminWindow? _window;
@@ -39,9 +40,10 @@ public sealed class AdminWindowService : IDisposable
/// <param name="playerSigning">
/// Contrôle du trousseau des joueurs avant une publication signée (null : signatures non vérifiées, pas d'avertissement).
/// </param>
/// <param name="maintenanceChanged">Après une publication du mode maintenance : relecture immédiate par ce launcher.</param>
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes,
PlayerSigningCheck? playerSigning = null)
PlayerSigningCheck? playerSigning = null, Action? maintenanceChanged = null)
{
_owner = owner;
_modules = modules;
@@ -52,6 +54,7 @@ public sealed class AdminWindowService : IDisposable
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
_apiUrlError = apiUrlError;
_playerSigning = playerSigning;
_maintenanceChanged = maintenanceChanged;
}
public bool IsAvailable => _apiUrl is not null;
@@ -93,7 +96,8 @@ public sealed class AdminWindowService : IDisposable
SettingsPath = _settingsPath,
LauncherVersion = _launcherVersion,
RootKeys = LauncherUpdateService.LoadRootKeys(),
PlayerSigning = _playerSigning
PlayerSigning = _playerSigning,
MaintenanceChanged = _maintenanceChanged
});
window = new AdminWindow(shell);
if (_owner.IsVisible) window.Owner = _owner;
+161 -2
View File
@@ -10,6 +10,7 @@
xmlns:asig="clr-namespace:GameLauncher.Modules.Admin.Signing;assembly=GameLauncher.Modules.Admin"
xmlns:afil="clr-namespace:GameLauncher.Modules.Admin.Files;assembly=GameLauncher.Modules.Admin"
xmlns:afb="clr-namespace:GameLauncher.Modules.Admin.Feedback;assembly=GameLauncher.Modules.Admin"
xmlns:amnt="clr-namespace:GameLauncher.Modules.Admin.Maintenance;assembly=GameLauncher.Modules.Admin"
xmlns:conv="clr-namespace:GameLauncher.Converters">
<!--
@@ -367,6 +368,19 @@
</DockPanel>
</Border>
<!-- Mode maintenance en cours (tous les administrateurs le voient ; la page « Maintenance » exige maintenance.manage) -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}" Background="#26FF8A1F"
Visibility="{Binding IsMaintenanceActive, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Gérer" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.TrafficCone}" Command="{Binding OpenMaintenanceCommand}"
Visibility="{Binding CanOpenMaintenance, Converter={StaticResource BoolToVisibility}}" />
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.TrafficCone}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding MaintenanceText}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center" />
</DockPanel>
</Border>
<!-- Compte connecté -->
<Border Style="{StaticResource AdminCardStyle}">
<Grid>
@@ -2991,7 +3005,7 @@
<StackPanel>
<TextBlock Text="Clés autorisées à signer" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="D'après les appareils actifs des administrateurs autorisés à publier une version (propriétaires, administrateurs) ou le catalogue (éditeurs compris). Le trousseau est public : chacun peut y lire le nom des appareils (« Nom — appareil »)." />
Text="D'après les appareils actifs des administrateurs autorisés à publier une version, le catalogue, les formulaires des demandes ou le mode maintenance (permissions de leur groupe). Le trousseau est public : chacun peut y lire le nom des appareils (« Nom — appareil »)." />
<TextBlock Text="{Binding ChangesText}" FontSize="12.5" FontWeight="SemiBold" Margin="0,0,0,10" />
<ItemsControl ItemsSource="{Binding Keys}" Focusable="False" />
<DockPanel Margin="0,10,0,0">
@@ -3014,13 +3028,130 @@
<StackPanel>
<TextBlock Text="Contenu en ligne" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="Versions et catalogue tels que les launchers les reçoivent, vérifiés avec le trousseau publié et avec le trousseau proposé." />
Text="Versions, catalogue, formulaires des demandes et mode maintenance tels que les launchers les reçoivent, vérifiés avec le trousseau publié et avec le trousseau proposé." />
<ItemsControl ItemsSource="{Binding Contents}" Focusable="False" />
</StackPanel>
</Border>
</StackPanel>
</DataTemplate>
<!-- ============================================================ Page « Maintenance » -->
<DataTemplate DataType="{x:Type amnt:MaintenancePanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" 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" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding ShowPublishingDisabled, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5"
Text="Publication du mode maintenance non configurée sur le serveur : renseignez LauncherApi:Catalog:PublicFile (le fichier est alors écrit à côté du catalogue) ou LauncherApi:Keyring:PublicFile (server/deploy/LISEZMOI.md, étape 3 decies)." />
</Border>
<!-- État publié -->
<Border x:Name="StateCard" Style="{StaticResource AdminCardStyle}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border x:Name="StateBadge" Style="{StaticResource AdminIconBadgeStyle}" Width="46" Height="46" CornerRadius="15">
<Path x:Name="StateIcon" Style="{StaticResource AdminIconStyle}" Width="22" Height="22" Data="{StaticResource Icon.CheckCircle}"
Fill="{DynamicResource SuccessBrush}" />
</Border>
<StackPanel Grid.Column="1" Margin="14,0,12,0" VerticalAlignment="Center">
<TextBlock Text="{Binding StateTitle}" FontSize="17" FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Text="{Binding StateText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
<TextBlock FontSize="12" Margin="0,6,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding PublicUrl, Converter={StaticResource StringToVisibility}}">
<Run Text="Fichier lu par les launchers :" />
<Run Text="{Binding PublicUrl, Mode=OneWay}" FontFamily="Cascadia Mono, Consolas, Courier New" />
</TextBlock>
</StackPanel>
</Grid>
</Border>
<!-- Message (actions : barre du bas, AdminFooter.MaintenancePanelViewModel) -->
<Border Style="{StaticResource AdminCardStyle}" IsEnabled="{Binding IsLoaded}">
<StackPanel>
<TextBlock Text="Message affiché aux joueurs" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="Facultatif : une précision sous « Maintenance en cours — merci de revenir plus tard » (raison, heure de retour prévue…). Texte brut, 6 lignes au plus, aucun lien ni adresse de site." />
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="80" MaxLength="400"
Text="{Binding Message, UpdateSourceTrigger=PropertyChanged}" ToolTip="Précision affichée aux joueurs (facultative)" />
<DockPanel Margin="0,6,0,0">
<TextBlock DockPanel.Dock="Right" Text="{Binding MessageCounter}" FontSize="11.5" Margin="12,0,0,0"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="{Binding MessageError}" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource DangerBrush}"
Visibility="{Binding HasMessageError, Converter={StaticResource BoolToVisibility}}" />
</DockPanel>
<!-- Aperçu : mêmes textes que la carte du launcher -->
<Border Margin="0,16,0,0" CornerRadius="16" Padding="22,18" BorderThickness="1" Background="#E61A0D09" BorderBrush="#80FF6A2B">
<StackPanel>
<DockPanel HorizontalAlignment="Center">
<Border DockPanel.Dock="Left" Width="34" Height="34" CornerRadius="11" VerticalAlignment="Center">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FF3B30" Offset="0" />
<GradientStop Color="#FF9500" Offset="1" />
</LinearGradientBrush>
</Border.Background>
<Path Data="{StaticResource Icon.TrafficCone}" Fill="White" Width="18" Height="18" Stretch="Uniform" />
</Border>
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding PreviewTitle}" FontSize="16" FontWeight="Bold" Foreground="#F4F6FB" />
<TextBlock Text="{Binding PreviewSubtitle}" FontSize="12.5" Foreground="#C8B4AC" />
</StackPanel>
</DockPanel>
<TextBlock Text="{Binding PreviewMessage}" Margin="0,12,0,0" FontSize="13" TextWrapping="Wrap" TextAlignment="Center"
Foreground="#F4F6FB" Visibility="{Binding PreviewMessage, Converter={StaticResource StringToVisibility}}" />
</StackPanel>
</Border>
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,8,0,0"
Text="Aperçu : dans le launcher, la carte s'affiche au centre de la fenêtre, sur un fond rouge / orange." />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,12,0,0"
Text="Publication signée par la clé de ce PC (autorisation « mode maintenance » du trousseau). Les launchers relisent l'état au démarrage, puis toutes les 1 à 2 minutes ; celui de ce PC bascule aussitôt." />
</StackPanel>
</Border>
<!-- Ce que voient les joueurs -->
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<TextBlock Text="Pendant la maintenance, chez les joueurs" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,6,0,0"
Text="• Bloqués : installation, mises à jour (automatiques ou non), vérification, réparation, localisation et lancement des jeux, raccourcis du Bureau, paramètres du launcher et des jeux, bouton « Aide », site officiel." />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
Text="• Toujours possibles : désinstallation des jeux (menu « … » ou Windows), menu d'administration, réduction et fermeture du launcher." />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
Text="• Non interrompus : téléchargements déjà commencés, parties en cours, mises à jour du launcher lui-même (signées par la clé racine)." />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0"
Text="• Hors ligne au démarrage : pas de maintenance (le jeu installé reste jouable). C'est un affichage pour les joueurs, pas une protection : si les serveurs du jeu doivent refuser les connexions, c'est à eux de le faire." />
</StackPanel>
</Border>
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsActive}" Value="True">
<Setter TargetName="StateCard" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="StateBadge" Property="Background" Value="#33FF8A1F" />
<Setter TargetName="StateIcon" Property="Data" Value="{DynamicResource Icon.TrafficCone}" />
<Setter TargetName="StateIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- ============================================================ Page « Fichiers du serveur » -->
<!-- Élément du fil d'Ariane (www › exovivalzombie › objects) -->
<DataTemplate DataType="{x:Type afil:FolderCrumb}">
@@ -3326,6 +3457,34 @@
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.MaintenancePanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
<DockPanel Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Recharger" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding RefreshCommand}"
ToolTip="Afficher l'état actuellement publié" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Modifier le message" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding UpdateMessageCommand}"
ToolTip="Publie le nouveau message (requête signée par votre clé) ; la maintenance continue"
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Terminer la maintenance"
ui:IconAssist.Icon="{StaticResource Icon.LockOpenVariant}" Command="{Binding DisableCommand}"
ToolTip="Les launchers des joueurs redeviennent utilisables (requête signée par votre clé)"
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource DangerButtonStyle}" Content="Activer la maintenance"
ui:IconAssist.Icon="{StaticResource Icon.TrafficCone}" Command="{Binding EnableCommand}"
ToolTip="Bloque tous les launchers des joueurs, sauf la désinstallation des jeux (requête signée par votre clé)"
Visibility="{Binding IsActive, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
</StackPanel>
<TextBlock Text="{Binding StateTitle}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="True"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.MyDevicesPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
+2
View File
@@ -91,6 +91,8 @@
<!-- Publication d'une version (phase 5) -->
<Geometry x:Key="Icon.CloudUpload">M14,13V17H10V13H7L12,8L17,13M19.35,10.03C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.03C2.34,8.36 0,10.9 0,14A6,6 0 0,0 6,20H19A5,5 0 0,0 24,15C24,12.36 21.95,10.22 19.35,10.03Z</Geometry>
<!-- Cône de chantier (mode maintenance) : bandes du cône et socle -->
<Geometry x:Key="Icon.TrafficCone">M9.6,2H14.4L15.2,5H8.8L9.6,2M8.3,7H15.7L16.7,11H7.3L8.3,7M6.8,13H17.2L18.2,17H5.8L6.8,13M3,19H21V22H3V19Z</Geometry>
<Geometry x:Key="Icon.Pause">M14,19H18V5H14M6,19H10V5H6V19Z</Geometry>
<Geometry x:Key="Icon.PackageVariant">M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z</Geometry>
<Geometry x:Key="Icon.Laptop">M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z</Geometry>
+72
View File
@@ -0,0 +1,72 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Mode maintenance (publié depuis le menu d'administration) : palette rouge / orange appliquée par-dessus le thème du
jeu, dans la fenêtre principale seulement (MainWindow l'ajoute à ses ressources pendant la maintenance, puis la
retire). Mêmes clés que Themes/Default.xaml : les vues, en {DynamicResource …}, changent de couleur à chaud.
Les clés « Maintenance* » ne servent qu'à la carte affichée au centre de la fenêtre.
-->
<!-- ============================================================ Palette -->
<Color x:Key="AccentColor">#FF3B30</Color>
<Color x:Key="Accent2Color">#FF9F0A</Color>
<Color x:Key="BackgroundColor">#140806</Color>
<SolidColorBrush x:Key="BackgroundBrush" Color="#140806" />
<SolidColorBrush x:Key="AccentBrush" Color="#FF453A" />
<SolidColorBrush x:Key="Accent2Brush" Color="#FF9F0A" />
<SolidColorBrush x:Key="OnAccentBrush" Color="White" />
<LinearGradientBrush x:Key="AccentGradientBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FF3B30" Offset="0" />
<GradientStop Color="#FF9500" Offset="1" />
</LinearGradientBrush>
<SolidColorBrush x:Key="BorderBrush" Color="#2EFF7A45" />
<SolidColorBrush x:Key="BorderStrongBrush" Color="#4DFF7A45" />
<SolidColorBrush x:Key="ChipBrush" Color="#991C0C08" />
<SolidColorBrush x:Key="ChipIconBoxBrush" Color="#33FF6A2B" />
<SolidColorBrush x:Key="BadgeBrush" Color="#24FF6A2B" />
<SolidColorBrush x:Key="MenuBrush" Color="#F21C0E0A" />
<SolidColorBrush x:Key="ActionBarBrush" Color="#EB170B08" />
<SolidColorBrush x:Key="WindowBorderBrush" Color="#66FF6A2B" />
<SolidColorBrush x:Key="RailBrush" Color="#CC140806" />
<!-- ============================================================ Fond : illustration du jeu fortement teintée -->
<LinearGradientBrush x:Key="HeroOverlayBrush" StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#F2140806" Offset="0" />
<GradientStop Color="#D9140806" Offset="0.5" />
<GradientStop Color="#C7140806" Offset="1" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="VignetteBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#B3140806" Offset="0" />
<GradientStop Color="#00140806" Offset="0.2" />
<GradientStop Color="#00140806" Offset="0.6" />
<GradientStop Color="#F2140806" Offset="1" />
</LinearGradientBrush>
<!-- Lueur rouge / orange derrière la carte de maintenance -->
<RadialGradientBrush x:Key="AmbientGlowBrush" Center="0.55,0.45" GradientOrigin="0.55,0.45" RadiusX="0.7" RadiusY="0.8">
<GradientStop Color="#66FF3B1F" Offset="0" />
<GradientStop Color="#33FF8A00" Offset="0.45" />
<GradientStop Color="#00FF8A00" Offset="1" />
</RadialGradientBrush>
<!-- ============================================================ Carte « Maintenance en cours » -->
<SolidColorBrush x:Key="MaintenanceCardBrush" Color="#E61A0D09" />
<SolidColorBrush x:Key="MaintenanceCardBorderBrush" Color="#80FF6A2B" />
<!-- Bandes de chantier (diagonales orange / brun), répétées -->
<DrawingBrush x:Key="MaintenanceStripesBrush" TileMode="Tile" Viewport="0,0,16,16" ViewportUnits="Absolute"
Viewbox="0,0,16,16" ViewboxUnits="Absolute" Stretch="None">
<DrawingBrush.Drawing>
<DrawingGroup>
<GeometryDrawing Brush="#FF2A130B" Geometry="M0,0H16V16H0Z" />
<GeometryDrawing Brush="#FFFF8A1F" Geometry="M0,0L8,0L0,8Z M16,0L16,8L8,16L0,16Z" />
</DrawingGroup>
</DrawingBrush.Drawing>
</DrawingBrush>
</ResourceDictionary>
+108 -11
View File
@@ -4,6 +4,7 @@ using System.IO;
using System.Net;
using System.Net.Http;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Core.Models;
using GameLauncher.Core.Services;
using GameLauncher.Core.Trust;
@@ -65,6 +66,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private string? _lastInstallDirectory;
private int _bannerVersion;
private bool _launchWhenReady;
private bool _isMaintenance;
public GameViewModel(LauncherConfig config, LauncherSettings settings, string settingsPath,
LauncherPreferences preferences, GameInstaller installer, ManifestClient manifestClient,
@@ -88,8 +90,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
LauncherVersion = launcherVersion;
ApplySettings();
PrimaryCommand = Command(ExecutePrimary, CanExecutePrimary);
CancelCommand = Command(Cancel, () => State == LauncherState.Busy && _operationCancellable);
// Mode maintenance : toutes les commandes sont désactivées, sauf la désinstallation, l'annulation d'une opération
// déjà commencée et la fermeture du bandeau (Command(…, allowedInMaintenance: true)).
PrimaryCommand = Command(ExecutePrimary, CanExecutePrimary, allowedInMaintenance: true);
CancelCommand = Command(Cancel, () => State == LauncherState.Busy && _operationCancellable, allowedInMaintenance: true);
CheckUpdatesCommand = Command(() => Fire(() => CheckForUpdatesAsync(userInitiated: true)),
() => State is LauncherState.NotInstalled or LauncherState.Ready or LauncherState.UpdateAvailable
or LauncherState.NeedsRepair or LauncherState.Broken);
@@ -101,12 +105,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
() => State is LauncherState.NotInstalled or LauncherState.Broken);
ForgetCommand = Command(Forget, () => State == LauncherState.Broken);
UninstallCommand = Command(() => Fire(async () => await UninstallAsync()),
() => State is LauncherState.Ready or LauncherState.UpdateAvailable or LauncherState.NeedsRepair or LauncherState.Broken);
() => State is LauncherState.Ready or LauncherState.UpdateAvailable or LauncherState.NeedsRepair or LauncherState.Broken,
allowedInMaintenance: true);
OpenFolderCommand = Command(() => { if (InstallPath is not null) _window.OpenFolder(InstallPath); },
() => InstallPath is not null && State != LauncherState.Broken && Directory.Exists(InstallPath));
OpenSettingsCommand = Command(OpenSettings);
OpenWebsiteCommand = Command(() => { if (HasWebsite) _window.OpenUrl(_config.WebsiteUrl!); }, () => HasWebsite);
DismissBannerCommand = Command(HideBanner);
DismissBannerCommand = Command(HideBanner, allowedInMaintenance: true);
}
// ------------------------------------------------------------------ Propriétés affichées
@@ -184,6 +189,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(ShowInstallInfo));
OnPropertyChanged(nameof(ShowRailBadge));
OnPropertyChanged(nameof(RailToolTip));
OnPropertyChanged(nameof(DisplayStatusTitle));
OnPropertyChanged(nameof(DisplayStatusDetail));
RefreshCommands();
// Lancement demandé par un raccourci pendant une vérification : traité à la fin de celle-ci.
@@ -196,12 +203,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
}
public bool IsBusy => State == LauncherState.Busy;
public bool ShowLocateHint => State == LauncherState.NotInstalled;
public bool ShowLocateHint => State == LauncherState.NotInstalled && !_isMaintenance;
public bool ShowBrokenActions => State == LauncherState.Broken;
public bool ShowInstallInfo => InstallPath is not null && State != LauncherState.NotInstalled;
public string PrimaryText => State switch
{
not (LauncherState.Busy or LauncherState.Running) when _isMaintenance => "MAINTENANCE",
LauncherState.Checking => "CHARGEMENT",
LauncherState.NotInstalled => "INSTALLER",
LauncherState.Ready => "JOUER",
@@ -215,6 +223,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// <summary>Clé d'icône (ressource "Icon.&lt;clé&gt;" dans Themes/Icons.xaml).</summary>
public string PrimaryIcon => State switch
{
not (LauncherState.Busy or LauncherState.Running) when _isMaintenance => "TrafficCone",
LauncherState.Checking => "Refresh",
LauncherState.NotInstalled => "Download",
LauncherState.UpdateAvailable => "CloudDownload",
@@ -244,10 +253,71 @@ public sealed class GameViewModel : ObservableObject, IDisposable
};
private string _statusTitle = "Chargement…";
public string StatusTitle { get => _statusTitle; private set => SetProperty(ref _statusTitle, value); }
public string StatusTitle
{
get => _statusTitle;
private set { if (SetProperty(ref _statusTitle, value)) OnPropertyChanged(nameof(DisplayStatusTitle)); }
}
private string _statusDetail = "";
public string StatusDetail { get => _statusDetail; private set => SetProperty(ref _statusDetail, value); }
public string StatusDetail
{
get => _statusDetail;
private set { if (SetProperty(ref _statusDetail, value)) OnPropertyChanged(nameof(DisplayStatusDetail)); }
}
/// <summary>Titre de la barre d'action : pendant une maintenance, celle-ci (sauf pendant une opération déjà commencée).</summary>
public string DisplayStatusTitle => _isMaintenance && State != LauncherState.Busy ? MaintenanceRules.Title : StatusTitle;
public string DisplayStatusDetail => !_isMaintenance || State == LauncherState.Busy
? StatusDetail
: State == LauncherState.Running
? "Le jeu déjà lancé n'est pas fermé. Seule la désinstallation des jeux reste possible."
: "Seule la désinstallation des jeux reste possible (menu « … »).";
// ------------------------------------------------------------------ Mode maintenance
/// <summary>
/// Mode maintenance publié depuis le menu d'administration (commun à tous les jeux, posé par le launcher) : tout est
/// bloqué sauf la désinstallation. Une opération déjà commencée se termine (elle reste annulable) ; un jeu déjà lancé
/// n'est pas fermé. À la fin de la maintenance, les mises à jour sont recherchées de nouveau.
/// </summary>
public bool IsMaintenance
{
get => _isMaintenance;
set
{
if (!SetProperty(ref _isMaintenance, value)) return;
if (value) _launchWhenReady = false; // lancement demandé par un raccourci : abandonné
OnPropertyChanged(nameof(PrimaryText));
OnPropertyChanged(nameof(PrimaryIcon));
OnPropertyChanged(nameof(DisplayStatusTitle));
OnPropertyChanged(nameof(DisplayStatusDetail));
OnPropertyChanged(nameof(ShowLocateHint));
OnPropertyChanged(nameof(ShowWebsite));
RefreshCommands();
Log.Info($"[{GameId}] Mode maintenance {(value ? "activé" : "terminé")}.");
// Fin de la maintenance : état et mises à jour à jour (mise à jour automatique selon le réglage du jeu).
if (!value && State is LauncherState.NotInstalled or LauncherState.Ready or LauncherState.UpdateAvailable
or LauncherState.NeedsRepair or LauncherState.Broken)
Fire(() => CheckForUpdatesAsync(userInitiated: false));
}
}
/// <summary>Bouton « Site officiel » de la barre de titre (masqué pendant une maintenance).</summary>
public bool ShowWebsite => HasWebsite && !_isMaintenance;
/// <summary>Action refusée pendant une maintenance : bandeau d'explication. Vrai si l'action doit s'arrêter.</summary>
private bool BlockedByMaintenance()
{
if (!_isMaintenance) return false;
Log.Info($"[{GameId}] Action refusée : mode maintenance.");
ShowBanner(MaintenanceRules.Title,
"Cette action est indisponible pendant la maintenance. Merci de revenir plus tard : seule la désinstallation reste possible.",
DialogKind.Warning);
return true;
}
private string _stepText = "";
public string StepText { get => _stepText; private set => SetProperty(ref _stepText, value); }
@@ -334,9 +404,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
public RelayCommand OpenWebsiteCommand { get; }
public RelayCommand DismissBannerCommand { get; }
private RelayCommand Command(Action execute, Func<bool>? canExecute = null)
private RelayCommand Command(Action execute, Func<bool>? canExecute = null, bool allowedInMaintenance = false)
{
var command = new RelayCommand(execute, canExecute);
var command = new RelayCommand(execute,
allowedInMaintenance ? canExecute : () => !_isMaintenance && (canExecute?.Invoke() ?? true));
_commands.Add(command);
return command;
}
@@ -348,6 +419,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private bool CanExecutePrimary() => State switch
{
LauncherState.Busy when _isMaintenance => _operationCancellable, // seule l'annulation reste possible
_ when _isMaintenance => false,
LauncherState.Checking or LauncherState.Running => false,
LauncherState.Busy => _operationCancellable,
_ => true
@@ -355,6 +428,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private void ExecutePrimary()
{
if (State != LauncherState.Busy && BlockedByMaintenance()) return;
switch (State)
{
case LauncherState.NotInstalled: Fire(InstallAsync); break;
@@ -578,6 +652,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task CheckForUpdatesAsync(bool userInitiated, bool background = false)
{
if (State is LauncherState.Busy or LauncherState.Running) return;
// Maintenance : ni recherche ni mise à jour automatique (reprises à la fin de la maintenance).
if (_isMaintenance)
{
if (userInitiated) BlockedByMaintenance();
return;
}
if (_launcherUpdates?.IsInstalling == true) return; // le launcher se met à jour puis redémarre
if (!background)
@@ -599,6 +679,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
if (State is LauncherState.Busy or LauncherState.Running) return; // une opération ou le jeu a démarré entre-temps
if (_launcherUpdates?.IsInstalling == true) return;
RefreshInstallState();
if (_isMaintenance) return; // maintenance commencée pendant la recherche : pas de mise à jour automatique
if (manifest is null)
{
@@ -649,11 +730,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// Exécute une opération longue : état "Busy", barre de progression, annulation,
/// gestion des erreurs, puis nouvelle détection de l'état.
/// </summary>
/// <param name="allowedInMaintenance">Vrai pour la désinstallation seulement : seule opération permise pendant une maintenance.</param>
private async Task<bool> RunOperationAsync(string title,
Func<IProgress<OperationProgress>, CancellationToken, Task> work,
bool cancellable = true)
bool cancellable = true, bool allowedInMaintenance = false)
{
if (State == LauncherState.Busy) return false;
if (!allowedInMaintenance && BlockedByMaintenance()) return false;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
_operationCts = cts;
@@ -725,6 +808,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task InstallAsync()
{
if (BlockedByMaintenance()) return;
var manifest = await TryFetchManifestAsync();
if (manifest is null)
{
@@ -756,6 +840,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task UpdateAsync()
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
@@ -783,6 +868,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// </param>
private async Task RepairAsync(bool cacheIsFresh = false)
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
@@ -914,6 +1000,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task VerifyIntegrityAsync()
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
@@ -983,6 +1070,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task LocateAsync()
{
if (BlockedByMaintenance()) return;
var initial = InstallPath is not null ? Path.GetDirectoryName(InstallPath) : null;
var folder = _dialogs.PickFolder($"Où se trouve {GameName} ?", initial);
if (folder is null) return;
@@ -1046,6 +1134,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private void Forget()
{
if (BlockedByMaintenance()) return;
if (!_dialogs.Confirm("Oublier l'installation",
$"Le launcher ne considérera plus {GameName} comme installé dans « {InstallPath} ».\nAucun fichier ne sera supprimé.",
"Oublier", "Annuler", DialogKind.Question))
@@ -1124,7 +1213,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
_registry.Delete();
_shortcuts.RemoveDesktopShortcut();
_needsRepair = false;
}, cancellable: false);
}, cancellable: false, allowedInMaintenance: true);
if (ok && result is not null)
{
@@ -1250,6 +1339,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
public void LaunchFromShortcut()
{
_launchWhenReady = false;
if (_isMaintenance && State != LauncherState.Running)
{
ShowBanner(MaintenanceRules.Title,
$"{GameName} ne peut pas être lancé pendant la maintenance. {MaintenanceRules.Subtitle}", DialogKind.Warning, autoHide: false);
return;
}
switch (State)
{
case LauncherState.Ready:
@@ -1283,6 +1378,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private void Play()
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
var exePath = GetExecutablePath(dir);
@@ -1416,6 +1512,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// <summary>Paramètres propres à ce jeu (menu « … »). Les réglages communs sont dans les paramètres du launcher.</summary>
private void OpenSettings()
{
if (BlockedByMaintenance()) return;
var info = new GameSettingsInfo(GameName, InstallPath, InstalledVersionText, LatestVersionText, InstallSizeText,
_config.GameLogsDirectory, _config.GameDataDirectory);
var shortcut = new GameShortcutActions(
@@ -1,6 +1,8 @@
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Text.Json;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
@@ -32,6 +34,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
private readonly string _launcherVersion;
private IThemeTransition? _transition;
private GameViewModel? _selectedGame;
private MaintenanceDocument? _maintenance;
public LauncherViewModel(LauncherCatalog catalog, IWindowService window, IDialogService dialogs,
LauncherPreferences preferences, string launcherVersion,
@@ -52,7 +55,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
if (CloseToTray) HideToTray(); // option « La croix réduit le launcher dans la zone de notification »
else Quit();
});
OpenLauncherSettingsCommand = new RelayCommand(OpenLauncherSettings);
OpenLauncherSettingsCommand = new RelayCommand(OpenLauncherSettings, () => !IsMaintenance);
}
public ObservableCollection<GameViewModel> Games { get; } = new();
@@ -65,9 +68,55 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
public bool IsExitConfirmed { get; set; }
/// <summary>Bouton « Aide » affiché (adresse de l'API présente dans launcher.json).</summary>
/// <summary>Bouton « Aide » disponible (adresse de l'API présente dans launcher.json).</summary>
public bool IsHelpAvailable { get; set; }
/// <summary>Bouton « Aide » affiché : disponible, et pas de maintenance en cours.</summary>
public bool ShowHelpButton => IsHelpAvailable && !IsMaintenance;
// ------------------------------------------------------------------ Mode maintenance
/// <summary>
/// Mode maintenance publié depuis le menu d'administration (maintenance.json, vérifié par MaintenanceService) : thème
/// rouge / orange, message au centre, tout bloqué sauf la désinstallation des jeux et le menu d'administration.
/// </summary>
public bool IsMaintenance => _maintenance is { Enabled: true };
public string MaintenanceTitle => MaintenanceRules.Title;
public string MaintenanceSubtitle => MaintenanceRules.Subtitle;
/// <summary>Précision publiée par l'administrateur (texte brut, sans lien), sinon null.</summary>
public string? MaintenanceMessage => IsMaintenance ? _maintenance!.Message : null;
/// <summary>« Depuis le mardi 10 novembre à 14:05 » (heure de ce PC).</summary>
public string? MaintenanceSinceText => IsMaintenance && _maintenance!.Since is { } since
? "Depuis le " + since.ToLocalTime().ToString("dddd d MMMM 'à' HH:mm", CultureInfo.GetCultureInfo("fr-FR"))
: null;
/// <summary>Nouvel état du mode maintenance (null : rien de publié). Appelé sur le fil de l'interface.</summary>
public void ApplyMaintenance(MaintenanceDocument? maintenance)
{
var wasActive = IsMaintenance;
var next = maintenance is { Enabled: true } ? maintenance : null;
void Change()
{
_maintenance = next;
var active = IsMaintenance;
foreach (var game in Games) game.IsMaintenance = active;
OnPropertyChanged(nameof(MaintenanceMessage));
OnPropertyChanged(nameof(MaintenanceSinceText));
if (wasActive == active) return;
OnPropertyChanged(nameof(IsMaintenance));
OnPropertyChanged(nameof(ShowHelpButton));
OpenLauncherSettingsCommand.RaiseCanExecuteChanged();
}
// Début ou fin de la maintenance : même fondu enchaîné qu'un changement de jeu (palette, carte au centre).
if (wasActive != (next is not null) && _transition is not null) _transition.Run(Change);
else Change();
}
public RelayCommand MinimizeCommand { get; }
public RelayCommand CloseCommand { get; }
@@ -124,6 +173,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
public void AddGame(GameViewModel game)
{
game.IsMaintenance = IsMaintenance;
Games.Add(game);
OnPropertyChanged(nameof(IsMultiGame));
}
@@ -253,6 +303,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
private void OpenLauncherSettings()
{
if (IsMaintenance) return; // bouton désactivé pendant une maintenance
var vm = new LauncherSettingsViewModel(_preferences, LauncherName, _launcherVersion, _window, _launcherUpdates);
if (!_dialogs.ShowSettings(vm)) return;
+97 -8
View File
@@ -271,14 +271,15 @@
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Aide : avis, demande d'évolution, bug… pour le jeu sélectionné (absent sans « apiUrl » dans launcher.json) -->
<!-- Aide : avis, demande d'évolution, bug… pour le jeu sélectionné (absent sans « apiUrl » dans launcher.json,
masqué pendant une maintenance) -->
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Aide : avis, idée, bug…" Margin="0,0,4,0"
ui:IconAssist.Icon="{StaticResource Icon.MessageQuestion}" Click="OnHelpClick"
Visibility="{Binding DataContext.IsHelpAvailable, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
Visibility="{Binding DataContext.ShowHelpButton, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Site officiel"
ui:IconAssist.Icon="{StaticResource Icon.Web}"
Command="{Binding OpenWebsiteCommand}"
Visibility="{Binding HasWebsite, Converter={StaticResource BoolToVisibility}}" />
Visibility="{Binding ShowWebsite, Converter={StaticResource BoolToVisibility}}" />
<!-- Paramètres du launcher : communs à tous les jeux -->
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Paramètres du launcher" Margin="4,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Cog}"
@@ -295,8 +296,9 @@
</StackPanel>
</Grid>
<!-- ===================================================== Contenu -->
<Grid Grid.Row="1" Margin="58,6,34,20">
<!-- ===================================================== Contenu (masqué pendant une maintenance) -->
<Grid Grid.Row="1" Margin="58,6,34,20"
Visibility="{Binding IsMaintenance, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<!-- Notes de version : 370 en mode fenêtré, plus larges en plein écran (jusqu'à 560) -->
@@ -489,6 +491,87 @@
</Border>
</Grid>
<!-- ===================================================== Mode maintenance (publié depuis le menu d'administration) -->
<!--
Carte au centre de la fenêtre, à la place de la présentation et des notes de version. La palette rouge / orange
(Themes/Maintenance.xaml) est ajoutée aux ressources de la fenêtre par MainWindow.xaml.cs pendant la maintenance.
Textes : ceux du launcher (titre, sous-titre) et, au choix, la précision publiée par l'administrateur (texte brut).
-->
<Grid Grid.Row="1" Margin="58,6,34,20"
Visibility="{Binding IsMaintenance, Converter={StaticResource BoolToVisibility}}">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Focusable="False">
<Grid HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="660" Margin="0,8">
<!-- Halo de la carte (à part : l'ombre ne s'applique pas aux textes) -->
<Border CornerRadius="28" Background="{DynamicResource MaintenanceCardBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="44" ShadowDepth="0" Opacity="0.5" Color="#FF3B1F" />
</Border.Effect>
</Border>
<Border CornerRadius="28" Padding="44,26,44,34" BorderThickness="1"
BorderBrush="{DynamicResource MaintenanceCardBorderBrush}">
<StackPanel>
<!-- Bandes de chantier -->
<Border Width="132" Height="8" CornerRadius="4" Margin="0,0,0,26" HorizontalAlignment="Center"
Background="{DynamicResource MaintenanceStripesBrush}" />
<Grid Width="92" Height="92" HorizontalAlignment="Center">
<Border CornerRadius="30" Background="{DynamicResource AccentGradientBrush}" Opacity="0.7">
<Border.Effect>
<BlurEffect Radius="26" />
</Border.Effect>
</Border>
<Border CornerRadius="30" Background="{DynamicResource AccentGradientBrush}">
<Path Data="{StaticResource Icon.TrafficCone}" Fill="{DynamicResource OnAccentBrush}"
Width="46" Height="46" Stretch="Uniform" />
</Border>
</Grid>
<TextBlock Text="{Binding DataContext.MaintenanceTitle, RelativeSource={RelativeSource AncestorType=Window}}"
Margin="0,26,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontFamily="{DynamicResource HeroTitleFont}" FontSize="38" FontWeight="{DynamicResource HeroTitleWeight}"
FontStretch="{DynamicResource HeroTitleStretch}" />
<TextBlock Text="{Binding DataContext.MaintenanceSubtitle, RelativeSource={RelativeSource AncestorType=Window}}"
Margin="0,8,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontSize="18" Foreground="{DynamicResource TextSecondaryBrush}" />
<Border Width="72" Height="3" CornerRadius="1.5" Margin="0,22,0,0" HorizontalAlignment="Center"
Background="{DynamicResource AccentGradientBrush}" />
<!-- Précision publiée par l'administrateur : texte brut, sélectionnable, jamais interprété -->
<TextBox Text="{Binding DataContext.MaintenanceMessage, RelativeSource={RelativeSource AncestorType=Window}, Mode=OneWay}"
Margin="0,20,0,0" IsReadOnly="True" IsTabStop="False" BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource TextPrimaryBrush}" CaretBrush="Transparent" Cursor="Arrow"
FontSize="15" TextAlignment="Center" TextWrapping="Wrap" HorizontalAlignment="Stretch"
Visibility="{Binding DataContext.MaintenanceMessage, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource StringToVisibility}}">
<TextBox.Template>
<ControlTemplate TargetType="TextBox">
<ScrollViewer x:Name="PART_ContentHost" Focusable="False" VerticalScrollBarVisibility="Disabled"
HorizontalScrollBarVisibility="Disabled" />
</ControlTemplate>
</TextBox.Template>
</TextBox>
<TextBlock Text="{Binding DataContext.MaintenanceSinceText, RelativeSource={RelativeSource AncestorType=Window}}"
Margin="0,18,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontSize="12.5" Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding DataContext.MaintenanceSinceText, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource StringToVisibility}}" />
<Border HorizontalAlignment="Center" Margin="0,18,0,0" CornerRadius="14" Padding="14,8"
Background="{DynamicResource BadgeBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
<DockPanel>
<Path DockPanel.Dock="Left" Data="{StaticResource Icon.Lock}" Fill="{DynamicResource Accent2Brush}"
Width="13" Height="13" Stretch="Uniform" VerticalAlignment="Center" />
<TextBlock Margin="9,0,0,0" FontSize="12.5" TextWrapping="Wrap" VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="Installation, mises à jour et lancement suspendus. La désinstallation reste possible (menu « … »)." />
</DockPanel>
</Border>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</Grid>
<!-- ===================================================== Barre d'action -->
<Border Grid.Row="2" Margin="24,0,24,24" Padding="28,20" CornerRadius="22"
Background="{DynamicResource ActionBarBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
@@ -500,7 +583,7 @@
<StackPanel VerticalAlignment="Center" Margin="0,0,32,0">
<Grid>
<TextBlock Text="{Binding StatusTitle}" FontSize="16" FontWeight="SemiBold"
<TextBlock Text="{Binding DisplayStatusTitle}" FontSize="16" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" Margin="0,0,90,0" />
<TextBlock Text="{Binding StepText}" HorizontalAlignment="Right" VerticalAlignment="Center"
FontSize="12" Foreground="{DynamicResource TextMutedBrush}" />
@@ -509,9 +592,9 @@
Value="{Binding ProgressValue, Mode=OneWay}"
IsIndeterminate="{Binding IsProgressIndeterminate, Mode=OneWay}"
Visibility="{Binding ShowProgress, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding StatusDetail}" Margin="0,8,0,0" FontSize="12.5"
<TextBlock Text="{Binding DisplayStatusDetail}" Margin="0,8,0,0" FontSize="12.5"
TextTrimming="CharacterEllipsis" Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding StatusDetail, Converter={StaticResource StringToVisibility}}" />
Visibility="{Binding DisplayStatusDetail, Converter={StaticResource StringToVisibility}}" />
<StackPanel Orientation="Horizontal" Margin="0,10,0,0"
Visibility="{Binding ShowLocateHint, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Le jeu est déjà sur votre PC ?" Margin="0,0,10,0" FontSize="12.5"
@@ -533,6 +616,12 @@
<DataTrigger Binding="{Binding State}" Value="Running">
<Setter Property="Background" Value="{DynamicResource NeutralGradientBrush}" />
</DataTrigger>
<!-- Maintenance : bouton « MAINTENANCE » désactivé, aux couleurs de la maintenance -->
<DataTrigger Binding="{Binding IsMaintenance}" Value="True">
<Setter Property="Background" Value="{DynamicResource AccentGradientBrush}" />
<Setter Property="ToolTip" Value="Maintenance en cours : merci de revenir plus tard." />
<Setter Property="ToolTipService.ShowOnDisabled" Value="True" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
+42
View File
@@ -22,10 +22,52 @@ public partial class MainWindow : Window, IThemeTransition
private readonly Queue<DateTime> _logoClicks = new();
/// <summary>Palette rouge / orange du mode maintenance (Themes/Maintenance.xaml), ajoutée aux ressources de la fenêtre.</summary>
private static readonly Uri MaintenancePaletteUri = new("pack://application:,,,/GameLauncher;component/Themes/Maintenance.xaml");
private ResourceDictionary? _maintenancePalette;
private LauncherViewModel? _observed;
public MainWindow()
{
InitializeComponent();
PreviewKeyDown += OnPreviewKeyDown;
DataContextChanged += (_, _) => ObserveLauncher(DataContext as LauncherViewModel);
}
// ------------------------------------------------------------------ Mode maintenance
private void ObserveLauncher(LauncherViewModel? launcher)
{
if (_observed is not null) _observed.PropertyChanged -= OnLauncherPropertyChanged;
_observed = launcher;
if (launcher is null) return;
launcher.PropertyChanged += OnLauncherPropertyChanged;
ApplyMaintenancePalette(launcher.IsMaintenance);
}
private void OnLauncherPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(LauncherViewModel.IsMaintenance) && sender is LauncherViewModel launcher)
ApplyMaintenancePalette(launcher.IsMaintenance);
}
/// <summary>
/// Maintenance : la palette rouge / orange passe devant celle du thème du jeu, pour cette fenêtre seulement (les
/// ressources de Root sont trouvées avant celles de l'application). Le fondu enchaîné est fait par LauncherViewModel
/// (début et fin de la maintenance passent par <see cref="Run"/>, comme un changement de jeu).
/// </summary>
private void ApplyMaintenancePalette(bool active)
{
var merged = Root.Resources.MergedDictionaries;
if (active)
{
_maintenancePalette ??= new ResourceDictionary { Source = MaintenancePaletteUri };
if (!merged.Contains(_maintenancePalette)) merged.Add(_maintenancePalette);
}
else if (_maintenancePalette is not null)
{
merged.Remove(_maintenancePalette);
}
}
/// <summary>
@@ -0,0 +1,106 @@
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Maintenance;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Maintenance » du menu d'administration et bandeau du tableau de bord.</summary>
public class MaintenancePanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new MaintenanceAdminModule() });
[Fact]
public async Task The_owner_enables_the_maintenance_with_a_message_updates_it_then_ends_it()
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-maintenance-panel-tests", Guid.NewGuid().ToString("N"), "catalog");
Directory.CreateDirectory(directory);
await using var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json")
});
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
var dialogs = new FakeDialogs();
var notified = 0;
var environment = AdminTestKit.Environment(api, folder.Path, dialogs, Modules);
environment = new AdminEnvironment
{
Client = environment.Client, Modules = environment.Modules, Games = environment.Games, Themes = environment.Themes,
Dialogs = environment.Dialogs, Ui = environment.Ui, SettingsPath = environment.SettingsPath, KeysDirectory = environment.KeysDirectory,
LauncherVersion = environment.LauncherVersion, Time = environment.Time, StartTimers = false,
MaintenanceChanged = () => notified++
};
await using var shell = new AdminShellViewModel(environment);
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(MaintenancePanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<MaintenancePanelViewModel>(workspace.SelectedPanel!.ViewModel);
Assert.False(page.HasError, page.ErrorMessage);
Assert.False(page.IsActive);
Assert.Equal("Launchers accessibles", page.StateTitle);
Assert.Contains("jamais", page.StateText);
Assert.True(page.EnableCommand.CanExecute(null));
Assert.False(page.DisableCommand.CanExecute(null));
// Message avec un lien : refusé avant tout envoi.
page.Message = "Infos sur discord.gg/exovival";
Assert.True(page.HasMessageError);
await page.EnableCommand.ExecuteAsync();
Assert.False(page.IsActive);
Assert.Empty(dialogs.Confirmations);
// Activation (confirmée) : publiée, ce launcher prévenu.
page.Message = " Mise à jour des serveurs.\r\nRetour vers 18 h. ";
Assert.False(page.HasMessageError);
Assert.Equal("Mise à jour des serveurs.\nRetour vers 18 h.", page.PreviewMessage);
await page.EnableCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.IsActive);
Assert.Equal("Maintenance en cours", page.StateTitle);
Assert.Contains("Depuis le", page.StateText);
Assert.Equal("Mise à jour des serveurs.\nRetour vers 18 h.", page.PublishedMessage);
Assert.Equal(1, notified);
Assert.Single(dialogs.Confirmations);
Assert.False(page.UpdateMessageCommand.CanExecute(null)); // message inchangé
// Tableau de bord : bandeau « Maintenance en cours ».
workspace.Navigate(DashboardPanelViewModel.PanelId);
await workspace.Activation;
var dashboard = Assert.IsType<DashboardPanelViewModel>(workspace.SelectedPanel!.ViewModel);
await dashboard.RefreshCommand.ExecuteAsync(); // déjà affiché à la connexion : « Actualiser »
Assert.True(dashboard.IsMaintenanceActive, dashboard.ErrorMessage);
Assert.Contains("Maintenance en cours depuis le", dashboard.MaintenanceText);
Assert.True(dashboard.CanOpenMaintenance);
// Nouveau message : même maintenance (même début).
workspace.Navigate(MaintenancePanelViewModel.PanelId);
await workspace.Activation;
var since = (await workspace.Session.GetMaintenanceAsync()).Document.Since;
page.Message = "Retour vers 19 h.";
Assert.True(page.UpdateMessageCommand.CanExecute(null));
await page.UpdateMessageCommand.ExecuteAsync();
var state = await workspace.Session.GetMaintenanceAsync();
Assert.Equal("Retour vers 19 h.", state.Document.Message);
Assert.Equal(since, state.Document.Since);
Assert.Equal(2, notified);
// Fin refusée à la confirmation : rien n'est publié.
dialogs.ConfirmAnswer = false;
await page.DisableCommand.ExecuteAsync();
Assert.True(page.IsActive);
dialogs.ConfirmAnswer = true;
await page.DisableCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.False(page.IsActive);
Assert.Equal("", page.Message);
Assert.Contains("terminée", page.StateText);
Assert.Equal(3, notified);
Assert.False((await workspace.Session.GetMaintenanceAsync()).Document.Enabled);
}
}
@@ -127,11 +127,12 @@ public class SigningPanelTests
await workspace.Activation;
var page = Assert.IsType<SigningPanelViewModel>(workspace.SelectedPanel!.ViewModel);
// Formulaires d'origine : rien à signer ; affichés après les versions en ligne et le catalogue.
// Formulaires d'origine : rien à signer ; affichés après les versions en ligne et le catalogue, avant le mode maintenance.
Assert.False(page.HasError, page.ErrorMessage);
Assert.StartsWith("Version en ligne de EZR", page.Contents[0].Title);
Assert.StartsWith("Catalogue", page.Contents[^2].Title);
var forms = page.Contents[^1];
Assert.StartsWith("Catalogue", page.Contents[^3].Title);
Assert.Equal("Mode maintenance", page.Contents[^1].Title);
var forms = page.Contents[^2];
Assert.Equal("Formulaires des demandes", forms.Title);
Assert.StartsWith("Formulaires d'origine", forms.Text);
Assert.True(forms.IsValid);
@@ -140,7 +141,7 @@ public class SigningPanelTests
// les launchers ; le trousseau proposé (propriétaire : autorisation « formulaires ») les rendra valides.
await camille.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom(), 0);
await page.RefreshCommand.ExecuteAsync();
forms = page.Contents[^1];
forms = page.Contents[^2];
Assert.Equal("Formulaires des demandes (révision 1)", forms.Title);
Assert.False(forms.IsValid);
Assert.False(forms.WillBreak);
@@ -150,7 +151,7 @@ public class SigningPanelTests
// Trousseau n° 2 avec l'autorisation « formulaires » : valides.
await camille.PublishKeyringAsync(SigningKit.Keyring(root, 2, Entry(SigningScopes.All.ToArray())));
await page.RefreshCommand.ExecuteAsync();
forms = page.Contents[^1];
forms = page.Contents[^2];
Assert.True(forms.IsValid, forms.Text);
Assert.Contains("Camille", forms.Text);
Assert.Contains("valide pour les launchers", forms.Text);
@@ -0,0 +1,94 @@
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Tests.Contracts;
/// <summary>Règles du mode maintenance (communes au menu, au serveur et au launcher) et autorisation du trousseau.</summary>
public class MaintenanceRulesTests
{
private static readonly DateTimeOffset Since = DateTimeOffset.FromUnixTimeSeconds(1_794_300_000);
public static MaintenanceDocument Active(long revision = 1, string? message = null) =>
new() { Revision = revision, Enabled = true, Message = message, Since = Since };
[Fact]
public void The_message_is_plain_text_without_links_and_bounded()
{
Assert.Equal("Retour vers 18 h.\nMerci !", MaintenanceRules.CleanMessage(" Retour vers 18 h. \r\nMerci !\t\n\n\n\n", out var problem));
Assert.Null(problem);
Assert.Null(MaintenanceRules.CleanMessage(" ", out problem));
Assert.Null(problem);
foreach (var linked in new[] { "Infos sur discord.gg/exovival", "Voir https://exemple.fr", "www.site.com", "hxxp://piege", "contact@studio.fr" })
{
Assert.Null(MaintenanceRules.CleanMessage(linked, out problem));
Assert.Contains("lien", problem);
}
MaintenanceRules.CleanMessage(new string('a', MaintenanceRules.MaxMessageLength + 1), out problem);
Assert.Contains("300", problem);
MaintenanceRules.CleanMessage(string.Join("\n", Enumerable.Range(1, MaintenanceRules.MaxMessageLines + 1)), out problem);
Assert.Contains("lignes", problem);
MaintenanceRules.CleanMessage("Texte‮esrevni", out problem); // inversion du sens d'écriture
Assert.NotNull(problem);
}
[Fact]
public void Documents_are_validated_and_signed_on_a_canonical_digest()
{
var errors = new List<string>();
Assert.True(MaintenanceRules.Validate(Active(message: "Mise à jour des serveurs."), errors), string.Join(" ", errors));
Assert.True(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false }, errors));
Assert.False(MaintenanceRules.Validate(Active() with { Revision = 0 }, errors = new()));
Assert.False(MaintenanceRules.Validate(Active() with { Since = null }, errors = new()));
Assert.False(MaintenanceRules.Validate(Active() with { Since = Since.AddMilliseconds(500) }, errors = new()));
Assert.False(MaintenanceRules.Validate(Active(message: " non nettoyé "), errors = new()));
Assert.False(MaintenanceRules.Validate(Active(message: "Voir www.site.com"), errors = new()));
Assert.False(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false, Message = "x" }, errors = new()));
Assert.False(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false, Since = Since }, errors = new()));
Assert.False(MaintenanceRules.Validate(Active() with { Format = "autre" }, errors = new()));
// Début dans le futur (horloge du PC de l'administrateur déréglée) : refusé par le serveur.
Assert.False(MaintenanceRules.Validate(Active(), errors = new(), now: Since - TimeSpan.FromHours(1)));
// Condensé : état, message, début et révision couverts ; date de publication et signature non.
var digest = MaintenanceRules.Digest(Active(message: "A"));
Assert.Equal(digest, MaintenanceRules.Digest(Active(message: "A") with { UpdatedAt = DateTimeOffset.UtcNow }));
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(message: "B")));
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(2, "A")));
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(message: "A") with { Since = Since.AddSeconds(1) }));
Assert.NotEqual(MaintenanceRules.Digest(Active()), MaintenanceRules.Digest(new MaintenanceDocument { Revision = 1 }));
// Aller-retour du fichier public.
var parsed = MaintenanceRules.Parse(System.Text.Encoding.UTF8.GetBytes(MaintenanceRules.Serialize(Active(message: "A"))))!;
Assert.Equal(digest, MaintenanceRules.Digest(parsed));
}
[Fact]
public void Maintenance_has_its_own_permission_and_keyring_scope()
{
Assert.Contains(AdminPermissions.MaintenanceManage, AdminPermissions.All);
Assert.Equal("Mode maintenance", AdminPermissions.BuiltInLabel(AdminPermissions.MaintenanceManage));
Assert.True(AdminRoles.Has(AdminRoles.Owner, AdminPermissions.MaintenanceManage));
Assert.True(AdminRoles.Has(AdminRoles.Admin, AdminPermissions.MaintenanceManage));
Assert.False(AdminRoles.Has(AdminRoles.Editor, AdminPermissions.MaintenanceManage));
Assert.Equal(SigningScopes.Maintenance, ContentKinds.ScopeOf(ContentKinds.Maintenance));
Assert.Equal(ContentKinds.MaintenanceId, ContentSigning.SignedId(ContentKinds.Maintenance, "autre"));
Assert.Equal(new[] { SigningScopes.Maintenance }, SigningScopes.FromPermissions(new[] { AdminPermissions.MaintenanceManage }));
Assert.Equal("mode maintenance", SigningScopes.DisplayName(SigningScopes.Maintenance));
// Une clé autorisée « catalog » seulement ne peut pas signer le mode maintenance.
using var key = Signing.SigningKit.NewKey();
using var root = Signing.SigningKit.NewKey();
var document = Active();
var signature = Signing.SigningKit.Sign(key, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document));
var catalogOnly = Signing.SigningKit.Keyring(root, 1, Signing.SigningKit.Entry(key, "Léo", SigningScopes.Catalog));
Assert.False(ContentSigning.Verify(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signature, catalogOnly).Valid);
var allowed = Signing.SigningKit.Keyring(root, 2, Signing.SigningKit.Entry(key, "Nicolas", SigningScopes.Maintenance));
Assert.True(ContentSigning.Verify(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signature, allowed).Valid);
// Signature du mode maintenance réutilisée pour un autre contenu : refusée (séparation des types).
Assert.False(ContentSigning.VerifyWithKey(ContentKinds.Catalog, ContentKinds.CatalogId, MaintenanceRules.Digest(document), signature,
key.ExportSubjectPublicKeyInfo()));
}
}
@@ -0,0 +1,166 @@
using System.Net;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Storage;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Signing;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Server;
/// <summary>Mode maintenance : publication signée depuis le menu, fichier public, droits, révisions, audit.</summary>
public class MaintenanceModuleTests
{
private static async Task<(TestApi Api, string PublicDirectory)> StartAsync()
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-maintenance-tests", Guid.NewGuid().ToString("N"), "catalog");
Directory.CreateDirectory(directory);
var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json")
});
return (api, directory);
}
private static MaintenanceDocument ReadPublic(string directory) =>
MaintenanceRules.Parse(File.ReadAllBytes(Path.Combine(directory, "maintenance.json")))!;
[Fact]
public async Task Maintenance_is_enabled_updated_and_disabled_with_signed_public_files_and_audit()
{
var (api, directory) = await StartAsync();
await using var _ = api;
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var state = await owner.GetMaintenanceAsync();
Assert.Equal(0, state.Document.Revision);
Assert.False(state.Document.Enabled);
Assert.True(state.PublishingEnabled);
Assert.EndsWith("/catalog/maintenance.json", state.PublicUrl);
Assert.False(File.Exists(Path.Combine(directory, "maintenance.json")));
// Activation : fichier public signé par la clé de la session, vérifiable par les launchers.
state = await owner.PublishMaintenanceAsync(true, "Mise à jour des serveurs.\nRetour vers 18 h.", null, 0);
Assert.Equal(1, state.Document.Revision);
Assert.True(state.Document.Enabled);
Assert.NotNull(state.Document.Since);
Assert.NotNull(state.UpdatedBy);
var published = ReadPublic(directory);
Assert.True(published.Enabled);
Assert.Equal("Mise à jour des serveurs.\nRetour vers 18 h.", published.Message);
Assert.True(ContentSigning.VerifyWithKey(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(published),
published.Signature!, key.PublicKey));
// Nouveau message pendant la maintenance : même début.
var since = state.Document.Since;
state = await owner.PublishMaintenanceAsync(true, "Retour vers 19 h.", since, 1);
Assert.Equal(since, ReadPublic(directory).Since);
Assert.Equal("Retour vers 19 h.", ReadPublic(directory).Message);
// Fin : ni message ni début.
state = await owner.PublishMaintenanceAsync(false, "ignoré", null, 2);
published = ReadPublic(directory);
Assert.False(published.Enabled);
Assert.Null(published.Message);
Assert.Null(published.Since);
Assert.Equal(3, published.Revision);
var actions = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries
.Where(e => e.Action.StartsWith("maintenance.", StringComparison.Ordinal)).Select(e => e.Action).Reverse().ToList();
Assert.Equal(new[] { "maintenance.enable", "maintenance.update", "maintenance.disable" }, actions);
var enable = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "maintenance.enable");
Assert.Contains("Retour vers 18 h.", enable.Details);
Assert.Contains(key.KeyId, enable.Details);
// Page « Signature » : signature du mode maintenance en vigueur.
var signing = await owner.GetSigningStateAsync();
Assert.Equal(3, signing.Maintenance!.Revision);
Assert.False(signing.Maintenance.Enabled);
Assert.Equal(MaintenanceRules.Digest(published), signing.Maintenance.Digest);
}
[Fact]
public async Task Publication_needs_the_permission_the_session_key_the_current_revision_and_valid_content()
{
var (api, _) = await StartAsync();
await using var __ = api;
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
// Éditeur (sans maintenance.manage) : lecture permise (tableau de bord), publication refusée.
var (editorKey, _) = await api.AddAdminAsync(owner, "Léo", AdminRoles.Editor);
await using (var editor = await api.LoginAsync(editorKey))
{
var denied = await Assert.ThrowsAsync<ApiException>(() => editor.PublishMaintenanceAsync(true, null, null, 0));
Assert.Equal(403, denied.Status);
Assert.False((await editor.GetMaintenanceAsync()).Document.Enabled);
}
editorKey.Dispose();
// Sans session : refusé.
using (var anonymous = await api.Http.GetAsync(ApiRoutes.Prefix + "/" + ApiRoutes.Maintenance))
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
var state = await owner.PublishMaintenanceAsync(true, null, null, 0);
// Révision dépassée : 409, rien n'est écrasé.
var conflict = await Assert.ThrowsAsync<ApiException>(() => owner.PublishMaintenanceAsync(false, null, null, 0));
Assert.Equal(409, conflict.Status);
Assert.Equal(ApiErrorCodes.RevisionConflict, conflict.Code);
Assert.True((await owner.GetMaintenanceAsync()).Document.Enabled);
var login = await api.RawLoginAsync(key);
var since = state.Document.Since;
// Document signé par une autre clé que celle de la session, ou non signé : refusé.
using var stranger = SigningKit.NewKey();
var next = new MaintenanceDocument { Revision = 2, Enabled = true, Message = "Autre", Since = since };
foreach (var document in new[]
{
next with { Signature = SigningKit.Sign(stranger, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(next)) },
next
})
{
using var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.Maintenance,
new MaintenancePublishRequest { BaseRevision = 1, Document = document });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
// Signature de la session, mais contenu hors règles (lien), ou début modifié pendant la maintenance : refusé.
foreach (var document in new[] { next with { Message = "Infos sur discord.gg/exovival" }, next with { Since = since!.Value.AddHours(-1) } })
{
var signature = await owner.SignContentAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document));
using var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.Maintenance,
new MaintenancePublishRequest { BaseRevision = 1, Document = document with { Signature = signature } });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
// Signature d'un autre type de contenu (catalogue) sur le même condensé : refusée.
var mixed = await owner.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId, MaintenanceRules.Digest(next));
using (var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.Maintenance,
new MaintenancePublishRequest { BaseRevision = 1, Document = next with { Signature = mixed } }))
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
// Le client refuse aussi d'envoyer un message hors règles.
await Assert.ThrowsAsync<ArgumentException>(() => owner.PublishMaintenanceAsync(true, "Voir www.site.com", since, 1));
Assert.Equal(1, (await owner.GetMaintenanceAsync()).Document.Revision);
}
[Fact]
public async Task Without_a_public_folder_publication_is_not_configured()
{
await using var api = await TestApi.StartAsync();
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var state = await owner.GetMaintenanceAsync();
Assert.False(state.PublishingEnabled);
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.PublishMaintenanceAsync(true, null, null, 0));
Assert.Equal(503, refused.Status);
Assert.Equal(ApiErrorCodes.NotConfigured, refused.Code);
}
}
@@ -271,6 +271,74 @@ public class ContentTrustTests
Assert.Equal(2, server.Requests);
}
[Fact]
public async Task A_scope_granted_while_the_launcher_runs_is_found_by_downloading_the_keyring_again()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var nicolas = SigningKit.NewKey();
var server = new FakeServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas", SigningScopes.Releases)));
var clock = new ManualClock();
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
var catalog = new CatalogDocument { Revision = 1, Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Texte" } } };
catalog = catalog with
{
Signature = SigningKit.Sign(nicolas, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog))
};
Assert.False(await trust.VerifyCatalogAsync(catalog)); // clé connue, sans l'autorisation « catalog »
Assert.Equal(1, server.Requests);
// Trousseau republié avec l'autorisation (ex. « maintenance » après la mise à jour 3.3) : vu sans redémarrer,
// au plus une fois par KeyChangeRetryDelay.
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog)));
Assert.False(await trust.VerifyCatalogAsync(catalog));
Assert.Equal(1, server.Requests);
clock.Now += ContentTrustService.KeyChangeRetryDelay + TimeSpan.FromSeconds(1);
Assert.True(await trust.VerifyCatalogAsync(catalog));
Assert.Equal(2, server.Requests);
Assert.Equal(2, trust.CachedKeyring!.Sequence);
}
[Fact]
public async Task A_launcher_left_open_downloads_the_keyring_again_and_stops_trusting_a_removed_key()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var nicolas = SigningKit.NewKey();
using var compromised = SigningKit.NewKey();
var server = new FakeServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(compromised, "PC volé")));
var clock = new ManualClock();
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
await trust.VerifyManifestAsync(Manifest("1.2", compromised), "EZR");
// Clé retirée du trousseau (n° 2) : encore acceptée tant que le trousseau en mémoire est récent…
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas")));
clock.Now += TimeSpan.FromMinutes(10);
await trust.VerifyManifestAsync(Manifest("1.3", compromised), "EZR");
Assert.Equal(1, server.Requests);
// … puis refusée dès qu'il a dépassé son âge maximal (sans redémarrer le launcher).
clock.Now += ContentTrustService.KeyringMaxAge;
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.4", compromised), "EZR"));
Assert.Equal(2, server.Requests);
await trust.VerifyManifestAsync(Manifest("1.4", nicolas), "EZR");
// Serveur injoignable au téléchargement suivant : dernier trousseau vu (n° 2) conservé, pas d'erreur.
server.Keyring = null;
clock.Now += ContentTrustService.KeyringMaxAge + TimeSpan.FromMinutes(1);
await trust.VerifyManifestAsync(Manifest("1.5", nicolas), "EZR");
Assert.Equal(3, server.Requests);
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.6", compromised), "EZR"));
// Serveur compromis qui ressert l'ancien trousseau n° 1 : refusé (retour en arrière), la clé reste exclue.
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(compromised, "PC volé")));
clock.Now += ContentTrustService.KeyringMaxAge + TimeSpan.FromMinutes(1);
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.7", compromised), "EZR"));
Assert.Equal(2, trust.CachedKeyring!.Sequence);
}
[Fact]
public async Task A_signature_dated_far_in_the_future_does_not_block_later_releases()
{
@@ -0,0 +1,248 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Maintenance;
using GameLauncher.Core.Trust;
using GameLauncher.Tests.Admin;
namespace GameLauncher.Tests.Signing;
/// <summary>Mode maintenance lu par le launcher des joueurs : vérification, retour en arrière, hors ligne, changements signalés.</summary>
public class MaintenanceServiceTests
{
private static readonly Uri KeyringUrl = new("https://launcher.example/catalog/keyring.json");
private static readonly Uri MaintenanceUrl = new("https://launcher.example/catalog/maintenance.json");
/// <summary>Serveur simulé : trousseau et maintenance.json (absent : 404 ; Offline : réseau coupé).</summary>
private sealed class FakeServer
{
public byte[]? Keyring { get; set; }
public byte[]? Maintenance { get; set; }
public bool Offline { get; set; }
public int MaintenanceRequests { get; private set; }
public Task<byte[]> Download(Uri uri, long max, CancellationToken ct)
{
if (Offline) throw new HttpRequestException("hors ligne");
if (uri == KeyringUrl) return Keyring is { } k ? Task.FromResult(k) : throw new HttpRequestException("404", null, HttpStatusCode.NotFound);
MaintenanceRequests++;
if (Maintenance is not { } bytes) throw new HttpRequestException("404", null, HttpStatusCode.NotFound);
if (bytes.Length > max) throw new InvalidDataException("réponse trop volumineuse");
return Task.FromResult(bytes);
}
public void Serve(MaintenanceDocument document) => Maintenance = Encoding.UTF8.GetBytes(MaintenanceRules.Serialize(document));
}
private static MaintenanceDocument Signed(ECDsa key, long revision, bool enabled, string? message = null, DateTimeOffset? signedAt = null)
{
var document = new MaintenanceDocument
{
Revision = revision,
Enabled = enabled,
Message = enabled ? message : null,
Since = enabled ? DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeSeconds()) : null
};
return document with
{
Signature = SigningKit.Sign(key, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signedAt)
};
}
private static (MaintenanceService Service, FakeServer Server, List<MaintenanceDocument?> Changes) Create(
string folder, ECDsa root, ECDsa admin, ContentSigningMode mode, params string[] scopes)
{
var server = new FakeServer();
server.Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
SigningKit.Entry(admin, "Nicolas", scopes.Length == 0 ? new[] { SigningScopes.Maintenance } : scopes))));
var trust = mode == ContentSigningMode.Off
? null
: new ContentTrustService(server.Download, folder, KeyringUrl, new[] { SigningKit.Root(root) }, mode);
var service = new MaintenanceService(server.Download, MaintenanceUrl, trust);
var changes = new List<MaintenanceDocument?>();
service.Changed += changes.Add;
return (service, server, changes);
}
[Fact]
public async Task Signed_maintenance_is_applied_then_ended_and_each_change_is_reported_once()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require);
// Rien de publié (404) : pas de maintenance, rien à signaler.
Assert.False(await service.RefreshAsync());
Assert.Empty(changes);
var now = DateTimeOffset.UtcNow;
server.Serve(Signed(admin, 1, true, "Retour vers 18 h.", now.AddMinutes(-3)));
Assert.True(await service.RefreshAsync());
Assert.True(service.IsActive);
Assert.Equal("Retour vers 18 h.", Assert.Single(changes)!.Message);
// Même fichier : rien de nouveau.
Assert.True(await service.RefreshAsync());
Assert.Single(changes);
// Nouveau message : signalé.
server.Serve(Signed(admin, 2, true, "Retour vers 19 h.", now.AddMinutes(-2)));
await service.RefreshAsync();
Assert.Equal(2, changes.Count);
Assert.Equal("Retour vers 19 h.", changes[^1]!.Message);
// Coupure réseau pendant la maintenance : dernier état vérifié conservé.
server.Offline = true;
Assert.True(await service.RefreshAsync());
Assert.Equal(2, changes.Count);
server.Offline = false;
// Fin de la maintenance.
server.Serve(Signed(admin, 3, false, signedAt: now.AddMinutes(-1)));
Assert.False(await service.RefreshAsync());
Assert.False(service.IsActive);
Assert.Equal(3, changes.Count);
Assert.False(changes[^1]!.Enabled);
}
[Fact]
public async Task In_require_mode_forged_unsigned_or_replayed_maintenance_is_ignored()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
using var attacker = SigningKit.NewKey();
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require);
var now = DateTimeOffset.UtcNow;
// Non signé, signé par une clé absente du trousseau, ou contenu modifié après signature : ignorés.
var unsigned = Signed(admin, 1, true) with { Signature = null };
var forged = Signed(attacker, 1, true);
var tampered = Signed(admin, 1, true, "Message d'origine") with { Message = "Message modifié" };
foreach (var document in new[] { unsigned, forged, tampered })
{
server.Serve(document);
Assert.False(await service.RefreshAsync());
}
Assert.Empty(changes);
// Légitime : maintenance, puis fin (signée plus tard).
var old = Signed(admin, 1, true, signedAt: now.AddMinutes(-10));
server.Serve(old);
Assert.True(await service.RefreshAsync());
server.Serve(Signed(admin, 2, false, signedAt: now.AddMinutes(-5)));
Assert.False(await service.RefreshAsync());
// Un serveur compromis ressert l'ancienne maintenance légitime : refusée (révision et date de signature).
server.Serve(old);
Assert.False(await service.RefreshAsync());
// Même après un redémarrage du launcher (date de la dernière signature acceptée gardée dans l'état des signatures).
var (restarted, restartedServer, _) = Create(folder.Path, root, admin, ContentSigningMode.Require);
restartedServer.Serve(old);
Assert.False(await restarted.RefreshAsync());
// Hors limites, même bien signé : ignoré.
var linked = new MaintenanceDocument { Revision = 3, Enabled = true, Message = "Infos : discord.gg/x", Since = old.Since };
server.Serve(linked with
{
Signature = SigningKit.Sign(admin, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(linked))
});
Assert.False(await service.RefreshAsync());
}
[Fact]
public async Task A_key_without_the_maintenance_scope_cannot_start_a_maintenance()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require, SigningScopes.Catalog, SigningScopes.Releases);
server.Serve(Signed(admin, 1, true));
Assert.False(await service.RefreshAsync());
Assert.Empty(changes);
}
/// <summary>Horloge avancée à la main (trousseau).</summary>
private sealed class ManualClock : TimeProvider
{
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => Now;
}
[Fact]
public async Task A_maintenance_published_after_the_keyring_gained_the_scope_is_applied_without_restarting()
{
// Cas signalé en 3.3.0 : launcher ouvert, trousseau republié avec l'autorisation « maintenance » (étape 3 decies),
// puis maintenance activée. Le launcher gardait l'ancien trousseau (clé connue, sans l'autorisation) jusqu'au
// redémarrage : chaque relecture refusait la maintenance.
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
var server = new FakeServer
{
Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog))))
};
var clock = new ManualClock();
var trust = new ContentTrustService(server.Download, folder.Path, KeyringUrl, new[] { SigningKit.Root(root) },
ContentSigningMode.Require, time: clock);
var service = new MaintenanceService(server.Download, MaintenanceUrl, trust, clock);
var changes = new List<MaintenanceDocument?>();
service.Changed += changes.Add;
// Démarrage : rien de publié, trousseau n° 1 en mémoire.
Assert.NotNull(await trust.GetKeyringAsync());
Assert.False(await service.RefreshAsync());
// Trousseau n° 2 (autorisation « maintenance » ajoutée), puis maintenance activée quelques minutes plus tard.
server.Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 2,
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog, SigningScopes.Maintenance))));
clock.Now += TimeSpan.FromMinutes(3);
server.Serve(Signed(admin, 1, true, "Retour vers 18 h."));
// Relecture périodique suivante : trousseau retéléchargé, maintenance appliquée.
Assert.True(await service.RefreshAsync());
Assert.True(service.IsActive);
Assert.Equal("Retour vers 18 h.", Assert.Single(changes)!.Message);
}
[Fact]
public async Task Offline_at_startup_or_without_address_there_is_no_maintenance_and_warn_mode_applies_it()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
// Hors ligne au démarrage : jamais de maintenance (le jeu installé reste jouable).
var (offline, offlineServer, _) = Create(folder.Path, root, admin, ContentSigningMode.Require);
offlineServer.Offline = true;
Assert.False(await offline.RefreshAsync());
Assert.Null(offline.Current);
// Sans adresse (ni trousseau ni catalogue) : rien n'est lu.
Assert.Null(MaintenanceService.PublishedUrl(null, null));
Assert.Equal(MaintenanceUrl, MaintenanceService.PublishedUrl(KeyringUrl.AbsoluteUri, null));
var none = new MaintenanceService((_, _, _) => throw new InvalidOperationException("aucune requête attendue"), null, null);
Assert.False(await none.RefreshAsync());
// « warn » : document non signé appliqué (signalé dans le journal) ; « off » : appliqué sans vérification.
foreach (var mode in new[] { ContentSigningMode.Warn, ContentSigningMode.Off })
{
using var modeFolder = new TempFolder();
var (service, server, changes) = Create(modeFolder.Path, root, admin, mode);
server.Serve(Signed(admin, 1, true) with { Signature = null });
Assert.True(await service.RefreshAsync());
Assert.Single(changes);
}
// Fichier trop volumineux : ignoré.
var (big, bigServer, bigChanges) = Create(folder.Path, root, admin, ContentSigningMode.Off);
bigServer.Maintenance = new byte[MaintenanceRules.MaxBytes + 1];
Assert.False(await big.RefreshAsync());
Assert.Empty(bigChanges);
}
}
+35
View File
@@ -8,6 +8,7 @@ using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
@@ -173,6 +174,12 @@ internal sealed class FakeApi : HttpMessageHandler
return Json(_world.FormsState());
case ["v1", "feedback", "forms"] when method == HttpMethod.Put:
return Json(_world.FormsPublished(Read<FeedbackFormsPublishRequest>(body)));
// ---- Mode maintenance (en cours dans le monde de démonstration ; publication acceptée sans rien enregistrer)
case ["v1", "maintenance"] when get:
return Json(_world.Maintenance());
case ["v1", "maintenance"] when method == HttpMethod.Put:
return Json(_world.MaintenancePublished(Read<MaintenancePublishRequest>(body)));
case ["v1", "games", var gameId, "feedback"] when get:
return Json(_world.FeedbackList(gameId, QueryValue(query, "kind"), QueryValue(query, "status")));
case ["v1", "games", var gameId, "feedback", var feedbackId] when get:
@@ -717,10 +724,38 @@ internal sealed class SampleWorld
Digest = FeedbackFormRules.Digest(SampleData.SampleFeedbackForms()),
Signature = Signature(-1),
SignerName = $"{OwnerName} — PC-BUREAU"
},
// Mode maintenance de démonstration (en cours, comme la page « Maintenance »).
Maintenance = new MaintenanceSignatureState
{
Revision = 4,
Enabled = true,
Digest = MaintenanceRules.Digest(SampleData.SampleMaintenance(SampleMaintenanceMessage)),
Signature = Signature(0),
SignerName = $"{OwnerName} — PC-BUREAU"
}
};
}
// ------------------------------------------------------------------ Mode maintenance
public const string SampleMaintenanceMessage = "Mise à jour des serveurs de jeu.\nRetour prévu vers 18 h.";
/// <summary>Maintenance en cours (révision 4, activée par le propriétaire).</summary>
public MaintenanceState Maintenance() => new()
{
Document = SampleData.SampleMaintenance(SampleMaintenanceMessage) with { Signature = Signature(0) },
PublishingEnabled = true,
PublicUrl = FakeApi.Origin + "/catalog/maintenance.json",
UpdatedBy = $"{OwnerName} (PC-BUREAU)"
};
/// <summary>Publication acceptée sans rien enregistrer : document renvoyé tel quel (révision suivante).</summary>
public MaintenanceState MaintenancePublished(MaintenancePublishRequest request) => Maintenance() with
{
Document = request.Document with { UpdatedAt = DateTimeOffset.Now }
};
private ContentSignature Signature(int days) => new()
{
KeyId = OwnerKeyId,
+11
View File
@@ -9,6 +9,7 @@ using System.Windows.Media.Imaging;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
@@ -264,6 +265,16 @@ internal static class SampleData
/// Formulaires publiés de démonstration (révision 3) : bug renommé, avec cases à cocher, note sur 10, texte court et
/// case oui/non ; « autre » masqué. Fenêtre « Aide » (feedback-custom) et page « Formulaires des demandes » du menu.
/// </summary>
/// <summary>Mode maintenance de démonstration : en cours depuis 40 minutes (révision 4), message facultatif.</summary>
public static MaintenanceDocument SampleMaintenance(string? message) => new()
{
Revision = 4,
Enabled = true,
Message = message,
Since = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.AddMinutes(-40).ToUnixTimeSeconds()),
UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.AddMinutes(-40).ToUnixTimeSeconds())
};
public static FeedbackFormsDocument SampleFeedbackForms() => new()
{
Revision = 3,
+6 -3
View File
@@ -32,7 +32,9 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
* **Fenêtre principale** (`main-*`) : non installé, à jour, mise à jour disponible, téléchargement en
cours, préparation, installation introuvable, réparation nécessaire, jeu lancé, hors ligne, mise à jour
bloquée par sécurité, démarrage ; `main-multi` : plusieurs jeux (exemple `launcher.multi-jeux.example.json`).
bloquée par sécurité, démarrage, **mode maintenance** (`main-maintenance` avec la précision de l'administrateur,
`main-maintenance-plain` sans précision, jeu non installé) ; `main-multi` : plusieurs jeux (exemple
`launcher.multi-jeux.example.json`).
* **Fenêtres** : paramètres du jeu et du launcher, notes de version agrandies, choix du dossier
d'installation, mise à jour du launcher (téléchargement, version vérifiée, erreur), boîtes de dialogue
(information, succès, question, avertissement, erreur, action irréversible avec option), menu de la zone
@@ -51,8 +53,9 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
lien signalé, captures d'écran) et un formulaire des demandes en cours de modification
(`admin-page-feedback-forms-edit` : champ ajouté et déplié, erreur « lien » et avertissement « donnée
personnelle », aperçu tel que les joueurs le verront), puis un type de demande ajouté
(`admin-page-feedback-forms-new-type` : icône importée, captures d'écran désactivées, déplacé) ; rien
n'est publié.
(`admin-page-feedback-forms-new-type` : icône importée, captures d'écran désactivées, déplacé), la page
« Maintenance » (`admin-page-maintenance` : maintenance en cours ; `admin-page-maintenance-edit` : message avec
un lien, refusé, aperçu) ; rien n'est publié.
`--theme all` rend chaque scène dans chaque thème intégré (suffixe `@default`, `@exovival`).
+17
View File
@@ -12,6 +12,7 @@ using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Maintenance;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Modules.Admin.ViewModels;
@@ -48,6 +49,7 @@ internal sealed class AdminScenes : ISceneSet
(PagePrefix + "feedback-requests-detail", "Administration · demande d'un joueur (lien signalé, captures d'écran)"),
(PagePrefix + "feedback-forms-edit", "Administration · formulaire en cours de modification (champ ajouté, contrôles, aperçu)"),
(PagePrefix + "feedback-forms-new-type", "Administration · type de demande ajouté (icône importée, sans captures d'écran)"),
(PagePrefix + "maintenance-edit", "Administration · maintenance : message en cours de modification (lien refusé)"),
("admin-login-locked", "Administration · session verrouillée")
};
@@ -283,6 +285,21 @@ internal sealed class AdminScenes : ISceneSet
}
}
// ---- Maintenance : la page automatique montre la maintenance en cours ; ici, message modifié avec un lien (refusé,
// bouton « Modifier le message » désactivé) et aperçu tel que les joueurs le verraient. Rien n'est publié.
var maintenanceId = PagePrefix + "maintenance-edit";
if (capture.WantsAny(capture.Themes.Select(t => maintenanceId + Capture.Suffix(t))) &&
workspace.PanelItems.FirstOrDefault(p => p.Descriptor.Id == MaintenancePanelViewModel.PanelId) is { } maintenanceItem)
{
workspace.SelectedPanel = maintenanceItem;
await WaitForPanelAsync(capture, maintenanceItem, api);
if (maintenanceItem.ViewModel is MaintenancePanelViewModel maintenance)
maintenance.Message = "Mise à jour des serveurs de jeu.\nInfos en direct sur discord.gg/exemple";
else
capture.GlobalNotes.Add("⚠ Maintenance : page introuvable (module MaintenanceAdminModule déclaré dans App.CreateModules ?).");
await ShootAllThemesAsync(maintenanceId, "Administration · maintenance : message en cours de modification (lien refusé)");
}
// ---- Verrouillage
await shell.LockAsync("Session verrouillée après 10 minutes sans activité.");
await ShootAllThemesAsync("admin-login-locked", "Administration · session verrouillée");
+22 -4
View File
@@ -1,4 +1,5 @@
using System.Windows;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
@@ -23,7 +24,8 @@ internal sealed class PlayerScenes : ISceneSet
public PlayerScenes(Fixture fixture) => _fixture = fixture;
private sealed record MainState(string Id, string Title, Action<GameViewModel, Fixture> Apply);
/// <param name="Launcher">Facultatif : état commun à tous les jeux, posé après celui du jeu (mode maintenance…).</param>
private sealed record MainState(string Id, string Title, Action<GameViewModel, Fixture> Apply, Action<LauncherViewModel>? Launcher = null);
/// <summary>États de la fenêtre principale (textes repris de GameViewModel).</summary>
private static readonly MainState[] MainStates =
@@ -104,7 +106,22 @@ internal sealed class PlayerScenes : ISceneSet
"Les informations publiées sur le serveur n'ont pas une signature valide : elles sont ignorées. Vous pouvez tout de même jouer.",
DialogKind.Error);
}),
new("main-checking", "Fenêtre principale · démarrage (vérification)", (g, f) => { })
new("main-checking", "Fenêtre principale · démarrage (vérification)", (g, f) => { }),
// Mode maintenance (publié depuis le menu d'administration) : palette rouge / orange, carte au centre, tout bloqué
// sauf la désinstallation. Avec une précision de l'administrateur, puis sans (jeu non installé).
new("main-maintenance", "Fenêtre principale · mode maintenance (avec message)", (g, f) =>
{
Installed(g, f, "1.4.2", "1.4.2");
State(g, LauncherState.Ready, "Prêt à jouer", "Version 1.4.2");
}, l => l.ApplyMaintenance(SampleData.SampleMaintenance(
"Mise à jour des serveurs de jeu et sauvegarde des mondes.\nRetour prévu vers 18 h : merci de votre patience !"))),
new("main-maintenance-plain", "Fenêtre principale · mode maintenance (sans message, jeu non installé)", (g, f) =>
{
Poke.Set(g, "LatestVersion", "1.4.2");
Notes(g);
State(g, LauncherState.NotInstalled, "Prêt à installer",
$"Téléchargement : {Format.Bytes(DownloadSize)} • Espace requis : {Format.Bytes(InstalledSize)}");
}, l => l.ApplyMaintenance(SampleData.SampleMaintenance(null)))
};
public IEnumerable<SceneInfo> List(IReadOnlyList<string?> themes)
@@ -132,7 +149,7 @@ internal sealed class PlayerScenes : ISceneSet
{
var suffix = Capture.Suffix(theme);
foreach (var state in MainStates)
await MainAsync(capture, state.Id + suffix, state.Title, _fixture.Catalog, theme, state.Apply);
await MainAsync(capture, state.Id + suffix, state.Title, _fixture.Catalog, theme, state.Apply, state.Launcher);
if (_fixture.MultiCatalog is { } multi)
await MainAsync(capture, "main-multi" + suffix, "Fenêtre principale · plusieurs jeux (liste à gauche)", multi, theme,
@@ -153,12 +170,13 @@ internal sealed class PlayerScenes : ISceneSet
// ------------------------------------------------------------------ Fenêtre principale
private Task MainAsync(Capture capture, string id, string title, LauncherCatalog catalog, string? theme,
Action<GameViewModel, Fixture> apply) =>
Action<GameViewModel, Fixture> apply, Action<LauncherViewModel>? applyLauncher = null) =>
capture.ShootAsync(id, title, () =>
{
var launcher = _fixture.CreateLauncher(catalog, new RecordingDialogs(capture.Problem));
var game = launcher.SelectedGame ?? throw new InvalidOperationException("Aucun jeu dans launcher.json.");
apply(game, _fixture);
applyLauncher?.Invoke(launcher);
Poke.Call(game, "RefreshCommands");
var window = new MainWindow();
+53 -47
View File
@@ -1,78 +1,84 @@
# Launcher 3.2.1
# Launcher 3.3.1
LAUNCHER - NOTES DE MISE À JOUR
Signature des formulaires des demandes - Mise à jour du 10 novembre 2026
Correctif du mode maintenance - Mise à jour du 10 novembre 2026
==================================================================
La page « Signature » du menu d'administration affiche désormais l'état de signature des formulaires des demandes, sous les versions en ligne et le catalogue : formulaires d'origine, signés et valides, ou refusés par les launchers avec la raison et le remède.
Un launcher resté ouvert pendant la re-signature du trousseau ne voyait pas la maintenance activée ensuite : chaque relecture (toutes les 2 minutes) la refusait jusqu'au redémarrage. Il prend désormais le nouveau trousseau en compte tout seul, et le relit aussi régulièrement quand il reste ouvert longtemps.
Le serveur est à redéployer ; aucun bloc nginx à ajouter.
Aucune action sur le serveur. Le launcher se met à jour automatiquement.
------------------------------------------------------------------
EN BREF
------------------------------------------------------------------
- AMÉLIORÉ : page « Signature » : ligne « Formulaires des demandes »
dans « Contenu en ligne », sous les versions en ligne et le
catalogue.
- AMÉLIORÉ : vérification avec le trousseau publié (accepté ou refusé
par les joueurs, et pourquoi) et avec le trousseau proposé.
- SÉCURITÉ : export de la demande de trousseau : les formulaires qui
deviendraient invalides sont rappelés.
- SERVEUR : à redéployer ; aucun bloc nginx à ajouter.
- QUALITÉ : 1 nouveau test automatisé (356 au total).
- CORRIGÉ : mode maintenance activé après la re-signature du
trousseau non vu par un launcher déjà ouvert (redémarrage
nécessaire en 3.3.0).
- AMÉLIORÉ : trousseau retéléchargé quand un contenu est signé par
une clé connue mais pas encore autorisée pour ce contenu.
- SÉCURITÉ : trousseau relu au plus toutes les 30 minutes : un
appareil retiré n'est plus accepté par un launcher laissé ouvert.
- QUALITÉ : 3 nouveaux tests automatisés (370 au total).
------------------------------------------------------------------
PAGE « SIGNATURE »
CORRECTIF
------------------------------------------------------------------
- Contenu en ligne, dans l'ordre : versions en ligne, catalogue,
formulaires des demandes.
- Formulaires d'origine : « rien à signer tant que rien n'est
publié ».
- Formulaires publiés : révision, signataire et date ; « valide pour
les launchers », ou refus avec la raison (clé absente du trousseau,
autorisation « formulaires » manquante…) et le remède (republier
depuis la page « Formulaires des demandes »).
- Avertissement si la clé qui les a signés ne sera plus dans le
trousseau proposé ; rappel à l'export de la demande.
- Fichier des formulaires illisible sur le serveur : signalé, avec
la marche à suivre (historique feedback-forms-history/).
- Serveur antérieur à la 3.2.1 : la ligne n'apparaît pas.
- Cause : le launcher gardait en mémoire le trousseau lu au
démarrage. Il ne le retéléchargeait que pour une clé inconnue,
pas pour une clé connue qui vient de recevoir une autorisation
(« mode maintenance » ajoutée à l'étape 3 decies).
- Effet en 3.3.0 : « la clé … n'est pas autorisée à signer ce
contenu » dans le journal à chaque relecture, maintenance
ignorée jusqu'au redémarrage.
- Désormais : nouveau téléchargement du trousseau, au plus une
fois par minute ; la maintenance s'affiche à la relecture
suivante (2 minutes au plus).
- Concerne aussi les formulaires « Aide », le catalogue et les
versions des jeux signés par un appareil qui vient d'être
autorisé.
------------------------------------------------------------------
VÉRIFICATIONS PÉRIODIQUES DU LAUNCHER
------------------------------------------------------------------
- Mode maintenance : au démarrage (4 s au plus), puis toutes les
2 minutes (1 minute pendant une maintenance), et aussitôt après
une publication depuis le menu de ce PC.
- Versions des jeux (manifest) : au démarrage, puis toutes les
30 minutes (updateCheckIntervalMinutes).
- Trousseau : au démarrage ; après un échec réseau (1 minute) ;
clé inconnue ou non autorisée (1 minute au plus) ; au plus
toutes les 30 minutes (nouveau).
- Catalogue : au démarrage seulement.
- Mise à jour du launcher : au démarrage, et bouton « Rechercher »
des paramètres.
- Formulaires « Aide » : à l'ouverture de la fenêtre « Aide ».
------------------------------------------------------------------
SÉCURITÉ
------------------------------------------------------------------
- Accès inchangé : session et permission admins.manage.
- Vérification faite par le launcher avec le trousseau publié et la
clé racine intégrée, comme chez les joueurs.
- Rien de secret renvoyé : révision, condensé, signature et
signataire de formulaires publics.
- Chaque trousseau retéléchargé passe par les mêmes contrôles :
signature de la clé racine, numéro jamais en baisse, minimum
keyringMinSequence ; un ancien trousseau resservi est refusé.
- Serveur injoignable : dernier trousseau vérifié conservé.
------------------------------------------------------------------
SERVEUR
------------------------------------------------------------------
- À redéployer : server/deploy/LISEZMOI.md, étape 8.
- Aucun réglage ni bloc nginx à ajouter.
- Ordre indifférent entre serveur et launcher.
- Après la mise à jour, vérifier dans la page « Signature » que les
formulaires publiés sont valides pour les launchers ; sinon,
re-signer le trousseau avec l'autorisation « formulaires des
demandes ».
- Aucun changement.
------------------------------------------------------------------
DÉVELOPPEMENT
------------------------------------------------------------------
- Contrats : FeedbackFormsSignatureState, SigningState.FeedbackForms.
- Serveur : GET v1/signing renvoie la signature des formulaires en
vigueur (SigningModule).
- Menu : SigningPanelViewModel (ligne « Formulaires des demandes »).
- Outil ui-snapshot : formulaires de démonstration dans la scène
admin-page-signing.
- Tests : SigningPanelTests.cs.
- Documentation : docs/ADMIN-ARCHITECTURE.md (§ 12) mis à jour.
- Core : ContentTrustService (KeyChangeRetryDelay, KeyringMaxAge,
KeyMayBeNewer).
- Tests : ContentTrustTests (2), MaintenanceServiceTests (1).
- Documentation : README, docs/ADMIN-ARCHITECTURE.md,
server/deploy/LISEZMOI.md (étapes 3 quinquies et 3 decies).
La sécurité des mises à jour du launcher ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.