Compare commits

...
9 Commits
Author SHA1 Message Date
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
48 changed files with 2660 additions and 104 deletions

No files matched your search

+16 -3
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
+75 -1
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,76 @@ 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 : rien à revérifier. **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 :
@@ -976,6 +1048,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.
}
}
}
+45
View File
@@ -454,6 +454,48 @@ 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.
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 +626,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;
@@ -210,6 +211,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)
{
@@ -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.0</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);
}
}
@@ -0,0 +1,204 @@
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);
}
[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();
+67 -47
View File
@@ -1,78 +1,98 @@
# Launcher 3.2.1
# Launcher 3.3.0
LAUNCHER - NOTES DE MISE À JOUR
Signature des formulaires des demandes - Mise à jour du 10 novembre 2026
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.
Nouvelle page « Maintenance » dans le menu d'administration : un bouton met tous les launchers en mode maintenance. Le launcher passe au rouge / orange, affiche « Maintenance en cours, merci de revenir plus tard » au centre et bloque tout, sauf la désinstallation des jeux et le menu d'administration.
Le serveur est à redéployer ; aucun bloc nginx à ajouter.
Le serveur est à redéployer et un bloc nginx est à ajouter ; le trousseau est à re-signer après la diffusion de cette version.
------------------------------------------------------------------
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).
- NOUVEAU : page « Maintenance » (activer, modifier le message,
terminer), permission maintenance.manage.
- NOUVEAU : launcher en rouge / orange, carte « Maintenance en
cours » au centre, bouton « MAINTENANCE ».
- NOUVEAU : tout bloqué chez les joueurs, sauf la désinstallation
des jeux et le menu d'administration.
- AMÉLIORÉ : bandeau au tableau de bord, ligne dans la page
« Signature », catégorie dans le journal d'audit.
- SÉCURITÉ : mode maintenance signé (autorisation « mode
maintenance » du trousseau) et vérifié par les launchers.
- SERVEUR : à redéployer ; bloc nginx maintenance.json à ajouter.
- QUALITÉ : 11 nouveaux tests automatisés (367 au total).
------------------------------------------------------------------
PAGE « SIGNATURE »
PAGE « MAINTENANCE »
------------------------------------------------------------------
- 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.
- État publié : en cours depuis quand, par qui ; ou terminée.
- Message facultatif pour les joueurs : 300 caractères, 6 lignes,
texte brut, aucun lien ; compteur, contrôle en direct, aperçu.
- « Activer la maintenance » : confirmation détaillée.
- « Modifier le message » : même heure de début.
- « Terminer la maintenance » : confirmation.
- Le launcher de l'administrateur bascule aussitôt.
- Tableau de bord : bandeau « Maintenance en cours » pour tous les
administrateurs.
------------------------------------------------------------------
CHEZ LES JOUEURS
------------------------------------------------------------------
- Couleurs rouge / orange, carte au centre avec le message et
l'heure de début, fondu au début et à la fin.
- Bloqués : installation, mises à jour (automatiques comprises),
vérification, réparation, localisation, lancement (raccourcis
compris), paramètres, bouton « Aide », site officiel.
- Toujours possibles : désinstallation, menu d'administration.
- Non interrompus : téléchargement commencé, partie en cours, mise
à jour du launcher lui-même.
- Lu au démarrage avant la recherche des mises à jour, puis toutes
les 2 minutes (1 minute pendant une maintenance).
- Hors ligne au démarrage : pas de maintenance (le jeu installé
reste jouable).
------------------------------------------------------------------
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.
- Publication : permission maintenance.manage, requête signée et
document signé par la clé de la session, audit.
- Launchers : signature vérifiée selon contentSigning ; document
non signé, mal signé ou plus ancien que le dernier vu ignoré en
« require ».
- Aucun lien accepté dans le message ; titre fixe dans le code.
- Affichage pour les joueurs, pas une barrière de sécurité : les
serveurs du jeu restent seuls juges des connexions.
------------------------------------------------------------------
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 ».
- À redéployer : server/deploy/LISEZMOI.md, étapes 8 et 3 decies.
- nginx : bloc location = /catalog/maintenance.json
(server/deploy/nginx-catalog.conf).
- Aucun réglage à ajouter : fichier écrit à côté du trousseau.
- Ordre : serveur et nginx, launcher 3.3.0, puis trousseau re-signé
avec l'autorisation « mode maintenance ».
------------------------------------------------------------------
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.
- Contrats : MaintenanceDocument, MaintenanceRules,
ContentKinds.Maintenance, SigningScopes.Maintenance,
AdminPermissions.MaintenanceManage, ApiRoutes.Maintenance.
- Serveur : module Maintenance, MaintenanceStore.
- Launcher : MaintenanceService (Core), Themes/Maintenance.xaml,
GameViewModel.IsMaintenance, LauncherViewModel.ApplyMaintenance.
- Menu : MaintenanceAdminModule, MaintenancePanelViewModel.
- Outil ui-snapshot : scènes main-maintenance, main-maintenance-
plain, admin-page-maintenance, admin-page-maintenance-edit.
- Documentation : docs/ADMIN-ARCHITECTURE.md (§ 11 septies).
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.