Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b2167218 | ||
|
|
2e4d2ee76e | ||
|
|
7d9032fe20 | ||
|
|
ccd6b1a430 | ||
|
|
e4a21c6f5d | ||
|
|
1a7f45c38b | ||
|
|
b48d4222db | ||
|
|
51eecbb57a | ||
|
|
edd2633455 | ||
|
|
0e26e6eb11 | ||
|
|
916b53446a | ||
|
|
0e371518b5 | ||
|
|
153e498345 | ||
|
|
1e9f49a733 | ||
|
|
5f6c667030 | ||
|
|
93dd844221 | ||
|
|
5ad8025946 | ||
|
|
080000e13c | ||
|
|
3422b92f81 | ||
|
|
683cc7a9dc | ||
|
|
7bc229de53 | ||
|
|
83e3d61634 | ||
|
|
b088c6caa7 | ||
|
|
658a1cce72 | ||
|
|
bac8ebeb55 | ||
|
|
76eaa48342 |
No files matched your search
@@ -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)
|
||||
@@ -428,7 +429,7 @@ Le launcher (un seul `GameLauncher.exe`) vérifie au démarrage s'il existe une
|
||||
lui-même, la télécharge, l'installe et **redémarre tout seul**. Réglable dans **Paramètres du launcher** (roue crantée) :
|
||||
interrupteur « Mettre à jour le launcher automatiquement » (commun à tous les jeux) et bouton
|
||||
**« Vérifier les mises à jour »** (puis « Installer la x.y.z »). Option désactivée : un bandeau signale
|
||||
simplement qu'une nouvelle version existe.
|
||||
simplement qu'une nouvelle version existe — sauf pour une **version prioritaire** (ci-dessous), installée quand même.
|
||||
|
||||
### Sécurité
|
||||
|
||||
@@ -440,6 +441,7 @@ simplement qu'une nouvelle version existe.
|
||||
| Pas de rétrogradation | Seule une version **strictement supérieure** est installée ; le produit (`launcherId`) doit correspondre. |
|
||||
| Tailles bornées | `launcher-update.json` ≤ 256 Ko ; un serveur qui envoie plus que la taille annoncée est coupé (vaut aussi pour les fichiers du jeu). |
|
||||
| Retour arrière automatique | Si la nouvelle version ne démarre pas dans les 60 s (ou n'est pas la version annoncée), elle est arrêtée et l'ancienne remise en place. Cette version n'est plus proposée automatiquement (bouton manuel uniquement). |
|
||||
| Version prioritaire signée | L'indication « prioritaire » fait partie du contenu signé : un serveur compromis ne peut ni imposer une mise à jour, ni retirer la priorité d'un correctif. Elle ne permet pas d'installer autre chose qu'une version signée, strictement supérieure. |
|
||||
|
||||
### Déroulement
|
||||
|
||||
@@ -462,6 +464,22 @@ La mise à jour est refusée tant qu'un jeu est lancé ou qu'un téléchargement
|
||||
est dans un dossier protégé (Program Files), il ne peut pas se remplacer : le message l'explique.
|
||||
Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la version publiée se remplace.
|
||||
|
||||
### Version prioritaire (correctif critique)
|
||||
|
||||
Une version publiée avec **`--priority`** est installée au démarrage **même chez les joueurs qui ont
|
||||
désactivé** « Mettre à jour le launcher automatiquement ». Exemple : une faille critique est corrigée en 3.4.0.
|
||||
|
||||
| Point | Comportement |
|
||||
|---|---|
|
||||
| Réglage du joueur | **Jamais modifié** : `autoUpdate` reste à `false` dans `self-update.json`. Effet ponctuel : une fois le launcher à jour, les versions normales suivantes ne sont plus installées automatiquement chez lui. |
|
||||
| Versions suivantes | La priorité est **reprise** par les publications suivantes (`priorityVersion` dans `launcher-update.json`) : si la 3.5.0 (normale) sort après le correctif 3.4.0, un launcher encore en 3.3.x passe directement en 3.5.0 ; un launcher déjà en 3.4.0 n'est pas concerné. `--clear-priority` arrête la reprise (priorité publiée par erreur). |
|
||||
| Fenêtre de mise à jour | Pastille **« Prioritaire »** et texte « Mise à jour importante… votre réglage ne change pas ». « Plus tard » reste possible : nouvelle tentative au démarrage suivant, bandeau d'avertissement en attendant. |
|
||||
| Erreur de téléchargement | Retentée au démarrage suivant (une version normale n'est plus retentée automatiquement). |
|
||||
| La nouvelle version ne démarre pas | Retour arrière, puis plus de tentative automatique (sinon 60 s d'attente à chaque lancement) : bandeau d'avertissement et statut orange dans les Paramètres du launcher, installation par le bouton. |
|
||||
| Dossier protégé (Program Files) | Pas d'installation possible sans droits administrateur : bandeau d'avertissement. |
|
||||
| Option désactivée | Le launcher interroge désormais le serveur au démarrage (`launcher-update.json`, 1 à 2 Ko, 6 s au plus) pour savoir si une version prioritaire existe. |
|
||||
| Launchers antérieurs à 3.4.0 | Ignorent l'indication : ils ne suivent que leur propre réglage. La priorité ne vaut que pour les joueurs déjà en 3.4.0 ou plus. |
|
||||
|
||||
### Mise en place (une seule fois)
|
||||
|
||||
1. **Clés** : double-cliquez `tools\keygen-launcher.bat`. Choisissez une phrase de passe (12 caractères
|
||||
@@ -489,6 +507,9 @@ Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la ver
|
||||
signé par votre clé. Les launchers antérieurs à 1.1.3 reçoivent toujours le titre et la liste « à plat ».
|
||||
3. Double-cliquez `tools\publish-launcher-update.bat` : compilation, signature (phrase de passe demandée),
|
||||
puis envoi WinSCP — l'exe d'abord, `launcher-update.json` **en dernier**.
|
||||
**Correctif critique** : depuis une invite de commandes, `tools\publish-launcher-update.bat --priority`
|
||||
(confirmation demandée avant la signature). `--clear-priority` ne reprend pas la priorité déjà publiée.
|
||||
Le résumé affiché indique la priorité (« normale », « PRIORITAIRE » ou « version prioritaire x.y.z reprise »).
|
||||
|
||||
Vérification à tout moment :
|
||||
`dotnet run --project tools\LauncherPublisher -- verify --manifest https://…/launcher/launcher-update.json --public-key src\GameLauncher\Security\update-signing.pub`
|
||||
@@ -512,7 +533,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 +541,18 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
molette fait défiler ce qui est sous le curseur : elle ne change jamais une liste déroulante fermée (même si
|
||||
elle vient d'être utilisée), et une liste ou un champ qui ne peut plus défiler laisse défiler la page.
|
||||
|
||||
* **Page « Maintenance »** (permission `maintenance.manage`) : **active ou désactive le mode maintenance de tous les
|
||||
launchers** (tous les jeux), avec une précision facultative pour les joueurs (6 lignes, 300 caractères, aucun lien ;
|
||||
aperçu en direct). Chez les joueurs : fond et accents **rouge / orange**, carte « Maintenance en cours — merci de
|
||||
revenir plus tard » au centre (avec la précision et l'heure de début), bouton principal « MAINTENANCE » ; installation,
|
||||
mises à jour (automatiques comprises), vérification, réparation, localisation, lancement (raccourcis compris),
|
||||
paramètres, bouton « Aide » et site officiel **bloqués** ; seules la **désinstallation** (menu « … » ou Windows) et le
|
||||
menu d'administration restent disponibles. Lu au démarrage avant la recherche des mises à jour, puis toutes les 2 min
|
||||
(1 min pendant une maintenance) ; le launcher de l'administrateur bascule aussitôt. **Signé** (autorisation « mode
|
||||
maintenance » du trousseau) et vérifié comme le catalogue ; hors ligne au démarrage : pas de maintenance. Les
|
||||
téléchargements commencés se terminent, les parties en cours ne sont pas fermées, les mises à jour du launcher
|
||||
lui-même continuent. Le tableau de bord rappelle une maintenance en cours. Mise en place : `server/deploy/LISEZMOI.md`,
|
||||
étape 3 decies.
|
||||
* **Bouton « Aide »** (joueurs, barre de titre, bulle avec un point d'interrogation) : **avis**, **demande
|
||||
d'évolution**, **bug** ou **autre**, pour le **jeu sélectionné** (affiché, non modifiable pendant la saisie). Champs
|
||||
prédéfinis selon le type (note en étoiles, catégorie, gravité, fréquence, étapes pour reproduire…), contrôlés avant
|
||||
@@ -627,7 +660,9 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
|
||||
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
|
||||
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
|
||||
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
|
||||
reste jouable), et un catalogue refusé est ignoré. Le trousseau est retéléchargé sans redémarrer le launcher quand
|
||||
un contenu est signé par une clé absente ou non autorisée pour ce contenu (au plus une fois par minute), et au plus
|
||||
toutes les 30 minutes (une clé retirée n'est plus acceptée par un launcher laissé ouvert). Après le retrait d'une clé compromise, relevez
|
||||
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
|
||||
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
|
||||
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
|
||||
|
||||
@@ -68,7 +68,7 @@ Conséquences :
|
||||
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
|
||||
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
|
||||
| `src/GameLauncher.Modules.Feedback` | net8.0 | **Nouveau (7d).** Module « Aide » côté joueur : formulaire de demande (avis, évolution, bug, autre) généré à partir des formulaires partagés, envoi anonyme (§ 11 quinquies) |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
|
||||
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) ; **7f** : lecture et vérification du mode maintenance (`Maintenance/`) |
|
||||
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
|
||||
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
|
||||
| `server/GameLauncher.Server` | net8.0 (ASP.NET Core) | **Nouveau.** Launcher API : modules serveur, authentification, administrateurs, audit, outil en ligne de commande |
|
||||
@@ -324,6 +324,8 @@ premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les
|
||||
| `files.manage` | ✔ | ✔ | | Fichiers du serveur : consultation du site, suppression des fichiers inutilisés, nettoyage d'« objects » |
|
||||
| `feedback.read` | ✔ | ✔ | | Demandes des joueurs : lecture (consultations inscrites au journal) — § 11 quinquies |
|
||||
| `feedback.manage` | ✔ | ✔ | | Demandes des joueurs : statut, note interne, suppression |
|
||||
| `feedback.forms` | ✔ | ✔ | | Formulaires des demandes : modification et publication signée — § 11 sexies |
|
||||
| `maintenance.manage` | ✔ | ✔ | | Mode maintenance du launcher : activation, désactivation, message (publication signée) — § 11 septies |
|
||||
|
||||
Une permission intégrée ajoutée après l'installation (ex. `feedback.*`) est donnée **automatiquement** au groupe
|
||||
Propriétaire seulement : les groupes existants ne changent pas d'eux-mêmes (aucune élévation de droits sans l'action
|
||||
@@ -866,6 +868,78 @@ autorisation « formulaires »), signature par la clé de ce PC, requête signé
|
||||
« Recharger », rien n'est écrasé). « Annuler les modifications », « Revenir à l'origine » (formulaires d'origine dans
|
||||
l'éditeur, à publier ensuite), « Recharger ». Libellé d'audit : « Formulaires des demandes publiés ».
|
||||
|
||||
### 11 septies. Mode maintenance du launcher (7f ✅)
|
||||
|
||||
**Besoin** : depuis le menu, un bouton pour mettre **tous les launchers** (tous les jeux) en maintenance : thème rouge /
|
||||
orange, « Maintenance en cours — merci de revenir plus tard » au centre, et **tout bloqué** sauf la **désinstallation
|
||||
des jeux** et le **menu d'administration** ; puis la terminer.
|
||||
|
||||
**Document** (`Contracts/Maintenance/MaintenanceDocument`) : `maintenance.json` = format `gamelauncher-maintenance`,
|
||||
version 1, **révision**, `enabled`, `message` (facultatif), `since` (début, à la seconde ; absent hors maintenance),
|
||||
`updatedAt` (posé par le serveur, hors signature), signature ; révision 0 = jamais publié (inactif). Règles
|
||||
(`MaintenanceRules`, communes au menu, au serveur et au launcher) : format et version, révision ≥ 1 ; message
|
||||
nettoyé comme les textes des demandes (`FeedbackRules.CleanMultiline` : caractères de contrôle, invisibles, de mise en
|
||||
forme, d'inversion du sens d'écriture, d'usage privé refusés), **300 caractères, 6 lignes, aucun lien**
|
||||
(`FeedbackLinks`) ; maintenance active : début présent, à la seconde, pas plus de 10 min dans le futur (serveur) ;
|
||||
inactive : ni début ni message ; **8 Ko** au plus (sous la limite de 64 Ko de l'API : pas de bloc nginx d'API). Titre et
|
||||
sous-titre affichés aux joueurs **fixes** (dans le code) : seul le message est publié. Condensé signé : format, version,
|
||||
révision, état, message, début.
|
||||
|
||||
**Signature** : type de contenu `maintenance` (identifiant signé `maintenance`) et autorisation du trousseau
|
||||
**`maintenance`** (permission `maintenance.manage` : Propriétaire d'office, Administrateur sur une installation neuve,
|
||||
pas l'Éditeur). Les launchers 3.2 acceptent un trousseau qui la contient (autorisation inconnue sans effet) ; ceux
|
||||
d'avant le refusent et gardent le précédent (publier la 3.3 avant de re-signer). Un serveur compromis ne peut donc ni
|
||||
déclencher une fausse maintenance, ni prolonger une maintenance chez les launchers en « require » (date de signature
|
||||
non antérieure à la dernière acceptée, révision non inférieure).
|
||||
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `GET v1/maintenance` | administrateur connecté | | État en vigueur, publication possible, adresse publique, signataire (tableau de bord) |
|
||||
| `PUT v1/maintenance` | `maintenance.manage` | ✔ | `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 `revision_conflict` sinon), début inchangé pendant une maintenance ; audit `maintenance.enable`, `maintenance.disable` ou `maintenance.update` |
|
||||
|
||||
**Serveur** (`Modules/Maintenance/MaintenanceModule`, `Storage/MaintenanceStore`) : référence privée `maintenance.json`
|
||||
(+ `.bak`, historique de 20 révisions `maintenance-history/`), copie **publique** à côté du trousseau
|
||||
(`MaintenancePublicFileFullPath`, 644, atomique ; public d'abord) ; nom réservé (refus au démarrage si le catalogue ou
|
||||
le trousseau s'appellent ainsi). Document signé obligatoire. Le serveur ne bloque rien lui-même : l'API reste utilisable
|
||||
(c'est elle qui termine la maintenance). `GET v1/signing` renvoie aussi sa signature (`SigningState.Maintenance`).
|
||||
SQLite écarté : un seul petit document, publié en fichier de toute façon. nginx : `location = /catalog/maintenance.json`,
|
||||
sans cache.
|
||||
|
||||
**Launcher (joueur)** : `Core/Maintenance/MaintenanceService` lit `maintenance.json` (adresse déduite du trousseau,
|
||||
sinon du catalogue) **au démarrage**, en parallèle du catalogue et de la mise à jour du launcher, et l'attend **4 s au
|
||||
plus avant la recherche des mises à jour des jeux** (pas d'attente pour `--uninstall`) ; puis toutes les **2 min** (1 min
|
||||
pendant une maintenance) et aussitôt après une publication depuis le menu de ce PC (`IAdminContext.NotifyMaintenanceChanged`).
|
||||
8 Ko au plus, 8 s au plus ; règles contrôlées même si signé ; signature vérifiée par
|
||||
`ContentTrustService.VerifyMaintenanceAsync` (autorisation `maintenance`, date de signature non antérieure) ; révision
|
||||
inférieure à l'état connu refusée ; même fichier que la fois précédente (déjà accepté) : rien à revérifier ; fichier
|
||||
refusé : revérifié à chaque relecture (un trousseau re-signé avec l'autorisation `maintenance` pendant que le launcher
|
||||
tourne est retéléchargé et pris en compte, 3.3.1). **Rien n'est gardé sur le
|
||||
disque** : injoignable au démarrage = pas de maintenance (le jeu installé reste jouable hors ligne, un joueur sans
|
||||
connexion n'est jamais bloqué) ; pendant que le launcher tourne, une coupure ou un fichier refusé garde le dernier état
|
||||
vérifié, un 404 termine la maintenance.
|
||||
|
||||
Blocage (`LauncherViewModel.ApplyMaintenance`, `GameViewModel.IsMaintenance`) : commandes désactivées (sauf
|
||||
`UninstallCommand`, l'annulation d'une opération commencée et la fermeture du bandeau) **et** refus à l'entrée de chaque
|
||||
action (installation, mise à jour, réparation, vérification, localisation, oubli, lancement, raccourci du Bureau,
|
||||
paramètres, recherche de mises à jour, `RunOperationAsync` sauf la désinstallation) avec un bandeau d'explication ;
|
||||
recherche automatique et mise à jour automatique suspendues, relancées à la fin de la maintenance ; paramètres du
|
||||
launcher, bouton « Aide » et site officiel masqués ou désactivés. Non interrompus : opération déjà commencée (toujours
|
||||
annulable), jeu déjà lancé, mise à jour du launcher lui-même (signée par la clé racine : moyen de corriger un launcher
|
||||
défectueux). Affichage : `Themes/Maintenance.xaml` (palette rouge / orange, mêmes clés que les thèmes) ajouté aux
|
||||
ressources de la fenêtre principale seulement, carte au centre (cône de chantier `Icon.TrafficCone`, bandes de
|
||||
chantier, titre, sous-titre, message en texte brut sélectionnable, « Depuis le … », rappel « la désinstallation reste
|
||||
possible »), bouton principal « MAINTENANCE » désactivé ; début et fin avec le fondu enchaîné d'un changement de jeu.
|
||||
**Ce n'est pas une barrière de sécurité** : un launcher modifié peut l'ignorer ; les serveurs du jeu doivent refuser
|
||||
eux-mêmes les connexions si nécessaire.
|
||||
|
||||
**Menu d'administration** : module `MaintenanceAdminModule`, page **« Maintenance »** (`MaintenancePanelViewModel`,
|
||||
permission `maintenance.manage`, ordre 5, icône cône) : état publié (en cours depuis…, par…, terminée le…), message
|
||||
(compteur, contrôle en direct, aperçu de la carte), « Activer la maintenance » (confirmation détaillée), « Modifier le
|
||||
message », « Terminer la maintenance » (confirmation), contrôle du trousseau des joueurs avant chaque publication
|
||||
(`PlayerSigningCheck`), 409 : état relu, saisie gardée. Tableau de bord : bandeau « Maintenance en cours » pour tout
|
||||
administrateur (bouton « Gérer » avec la permission). Page « Signature » : ligne « Mode maintenance ». Journal d'audit :
|
||||
catégorie « Mode maintenance ».
|
||||
|
||||
## 12. Signature de bout en bout pour les joueurs (phase 7 ✅)
|
||||
|
||||
Sans signature, une prise de contrôle du VPS permettrait de distribuer un faux jeu. Désormais :
|
||||
@@ -894,6 +968,13 @@ côté du catalogue, 0644, atomique) et un historique ; audit `keyring.publish`
|
||||
des administrateurs expose désormais la clé publique des clés **actives** seulement (celle d'une clé en attente
|
||||
permettrait de calculer l'empreinte à dicter).
|
||||
|
||||
**Contenu en ligne** (page Signature, `GET v1/signing` : `SigningState`) : versions en ligne de chaque jeu, catalogue,
|
||||
puis **formulaires des demandes** (`FeedbackFormsSignatureState` : révision, condensé, signature, signataire ;
|
||||
révision 0 = formulaires d'origine intégrés au launcher, rien à signer ; fichier de référence illisible signalé).
|
||||
Chaque contenu est vérifié par le launcher avec le trousseau publié (accepté ou refusé par les joueurs, et pourquoi)
|
||||
et avec le trousseau proposé (« sa clé ne sera plus dans le trousseau proposé » ; l'export de la demande le rappelle).
|
||||
Un serveur antérieur à la 3.2.1 n'envoie pas les formulaires : la ligne n'apparaît pas.
|
||||
|
||||
**Signature du contenu** (`ContentSigning`) : propriété `signature` `{ keyId, signedAt, value }` dans le JSON
|
||||
(atomique avec le contenu, ignorée par les anciens launchers), sur le message `GameLauncher/Content/v1`
|
||||
(type, identifiant du jeu ou « catalog », date à la seconde, clé, condensé). Condensés canoniques, calculés par
|
||||
@@ -937,8 +1018,12 @@ Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (s
|
||||
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
|
||||
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
|
||||
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
|
||||
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
|
||||
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
|
||||
l'appelant : le cache est utilisé. Nouveau téléchargement du trousseau (mêmes contrôles) : après un échec réseau
|
||||
(1 min), quand un contenu est signé par une clé absente du trousseau **ou présente sans l'autorisation de ce contenu**
|
||||
(clé ajoutée ou autorisation donnée depuis : au plus une fois par minute, `KeyChangeRetryDelay`), et quand le trousseau
|
||||
en mémoire a plus de 30 minutes (`KeyringMaxAge`, à la vérification suivante : un launcher laissé ouvert cesse de faire
|
||||
confiance à une clé retirée). Avant la 3.3.1, une autorisation ajoutée à une clé déjà connue n'était vue qu'au
|
||||
redémarrage (mode maintenance refusé à chaque relecture après la re-signature du trousseau). Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
|
||||
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
|
||||
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
|
||||
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
|
||||
@@ -969,6 +1054,8 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
|
||||
| **7b** | Page « Fichiers du serveur » (suppression des fichiers inutilisés, nettoyage d'« objects »), période du journal d'audit, barres d'actions fixes et retour en haut, molette sans effet sur les listes fermées | ✅ |
|
||||
| **7c** | Groupes d'administrateurs et permissions personnalisées (page « Groupes et permissions », module serveur `Groups`, `admins.json` format 2) ; envoi des images du catalogue depuis la page « Jeux » (« Parcourir… », images nommées par leur empreinte et vérifiées par les joueurs) | ✅ |
|
||||
| **7d** | Demandes des joueurs : bouton « Aide » (avis, évolution, bug, autre ; anonyme, preuve de travail, limites), module serveur `Feedback`, page « Demandes des joueurs », purge automatique (demandes et journal d'audit) | ✅ |
|
||||
| **7e** | Formulaires des demandes modifiables depuis le menu (types, champs, icônes importées), signés (autorisation `forms`) | ✅ |
|
||||
| **7f** | Mode maintenance du launcher : page « Maintenance », module serveur `Maintenance`, document signé (autorisation `maintenance`), blocage côté joueur sauf la désinstallation, thème rouge / orange | ✅ |
|
||||
|
||||
Chaque phase compile, est testée et laisse le launcher utilisable. Pour les joueurs, rien ne
|
||||
change : le menu d'administration est discret et ne contacte le serveur qu'une fois ouvert.
|
||||
|
||||
@@ -32,7 +32,8 @@ public static class ApiModules
|
||||
new Modules.Publishing.PublishingModule(),
|
||||
new Modules.Signing.SigningModule(),
|
||||
new Modules.Files.FilesModule(),
|
||||
new Modules.Feedback.FeedbackModule()
|
||||
new Modules.Feedback.FeedbackModule(),
|
||||
new Modules.Maintenance.MaintenanceModule()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -248,6 +248,15 @@ public sealed class LauncherApiOptions
|
||||
/// <summary>Adresse publique des formulaires des demandes, calculée par <see cref="Validate"/>.</summary>
|
||||
public string FeedbackFormsPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Fichier public du mode maintenance (maintenance.json, à côté du trousseau ; null : publication désactivée), calculé
|
||||
/// par <see cref="Validate"/>. Les launchers le lisent dans le même dossier que le trousseau.
|
||||
/// </summary>
|
||||
public string? MaintenancePublicFileFullPath { get; private set; }
|
||||
|
||||
/// <summary>Adresse publique du mode maintenance, calculée par <see cref="Validate"/>.</summary>
|
||||
public string MaintenancePublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>Racine du site public pour la page « Fichiers du serveur » (null : page désactivée), calculée par <see cref="Validate"/>.</summary>
|
||||
public string? FilesRootFullPath { get; private set; }
|
||||
|
||||
@@ -403,11 +412,24 @@ public sealed class LauncherApiOptions
|
||||
? Path.Combine(Path.GetDirectoryName(keyringFile)!, FeedbackFormsFileName)
|
||||
: null;
|
||||
FeedbackFormsPublicUrl = new Uri(new Uri(KeyringPublicUrl), FeedbackFormsFileName).AbsoluteUri;
|
||||
|
||||
// Mode maintenance : même règle (les launchers en déduisent l'adresse à partir de celle du trousseau).
|
||||
MaintenancePublicFileFullPath = KeyringPublicFileFullPath is { } keyringPath
|
||||
? Path.Combine(Path.GetDirectoryName(keyringPath)!, MaintenanceFileName)
|
||||
: null;
|
||||
MaintenancePublicUrl = new Uri(new Uri(KeyringPublicUrl), MaintenanceFileName).AbsoluteUri;
|
||||
if (MaintenancePublicFileFullPath is { } maintenance &&
|
||||
new[] { CatalogPublicFileFullPath, KeyringPublicFileFullPath }.Any(f => string.Equals(f, maintenance, StringComparison.OrdinalIgnoreCase)))
|
||||
throw new InvalidOperationException(
|
||||
$"{Section}:Keyring:PublicFile / Catalog:PublicFile : « {MaintenanceFileName} » est réservé au mode maintenance, choisissez un autre nom.");
|
||||
}
|
||||
|
||||
/// <summary>Nom du fichier public des formulaires (à côté de keyring.json).</summary>
|
||||
public const string FeedbackFormsFileName = "feedback-forms.json";
|
||||
|
||||
/// <summary>Nom du fichier public du mode maintenance (à côté de keyring.json).</summary>
|
||||
public const string MaintenanceFileName = "maintenance.json";
|
||||
|
||||
private void ValidateGames()
|
||||
{
|
||||
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
|
||||
namespace GameLauncher.Server.Modules.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Mode maintenance du launcher (commun à tous les jeux, <see cref="MaintenanceStore"/>) :
|
||||
/// • GET v1/maintenance : état en vigueur, pour tout administrateur connecté (contenu public de toute façon) ;
|
||||
/// • PUT v1/maintenance : activation, désactivation ou nouveau message — permission maintenance.manage, requête signée
|
||||
/// ET document signé par la clé de la session (autorisation « maintenance » du trousseau, vérifiée par les launchers),
|
||||
/// règles complètes, révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// Le serveur ne bloque rien lui-même : ce sont les launchers des joueurs qui, en lisant le fichier public, bloquent tout
|
||||
/// sauf la désinstallation des jeux et le menu d'administration. L'API d'administration reste utilisable pendant la
|
||||
/// maintenance (c'est elle qui permet de la terminer).
|
||||
/// </summary>
|
||||
public sealed class MaintenanceModule : IApiModule
|
||||
{
|
||||
public string Name => "Maintenance";
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options) =>
|
||||
services.AddSingleton<MaintenanceStore>();
|
||||
|
||||
public void MapEndpoints(IEndpointRouteBuilder api)
|
||||
{
|
||||
api.MapGet(ApiRoutes.Maintenance, Get).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.Maintenance, Publish).RequirePermission(AdminPermissions.MaintenanceManage).RequireSignedRequest();
|
||||
}
|
||||
|
||||
private static IResult Get(MaintenanceStore store, AdminStore admins, ILogger<MaintenanceModule> logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Json(State(store, admins), ApiJson.Options);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogError(ex, "Lecture du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/ " +
|
||||
"dans les données privées du serveur.");
|
||||
}
|
||||
}
|
||||
|
||||
private static MaintenanceState State(MaintenanceStore store, AdminStore admins)
|
||||
{
|
||||
var current = store.Current;
|
||||
return new MaintenanceState
|
||||
{
|
||||
Document = current,
|
||||
PublishingEnabled = store.PublishingEnabled,
|
||||
PublicUrl = store.PublicUrl,
|
||||
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
private static IResult Publish(MaintenancePublishRequest request, HttpContext context, MaintenanceStore store, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, ILogger<MaintenanceModule> logger)
|
||||
{
|
||||
if (request.Document is null) return ApiResults.BadRequest("Document de maintenance absent.");
|
||||
var document = request.Document with { UpdatedAt = null };
|
||||
var actor = context.Admin();
|
||||
if (document.Signature is not { } signature)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Le mode maintenance doit être signé.");
|
||||
var now = time.GetUtcNow();
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.Maintenance, ContentKinds.MaintenanceId,
|
||||
MaintenanceRules.Digest(document), actor, now);
|
||||
|
||||
MaintenanceDocument published, previous;
|
||||
try
|
||||
{
|
||||
(published, previous) = store.Publish(document, request.BaseRevision, now);
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Fichier du mode maintenance illisible sur le serveur (voir ses journaux) : restaurez-le depuis maintenance-history/.");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication du mode maintenance");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire le fichier public du mode maintenance : vérifiez le dossier et ses droits " +
|
||||
"(server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
var action = published.Enabled switch
|
||||
{
|
||||
true when !previous.Enabled => "maintenance.enable",
|
||||
false when previous.Enabled => "maintenance.disable",
|
||||
_ => "maintenance.update"
|
||||
};
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{MaintenanceRules.Describe(published)} ; signé (clé {signature.KeyId})");
|
||||
logger.LogInformation("Mode maintenance {Action} par {Admin} (révision {Revision})", action, actor.Admin.DisplayName, published.Revision);
|
||||
return Results.Json(State(store, admins), ApiJson.Options);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ using System.Text.Json;
|
||||
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;
|
||||
@@ -13,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 et du catalogue ;
|
||||
/// • 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).
|
||||
@@ -33,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, 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();
|
||||
@@ -78,6 +81,55 @@ public sealed class SigningModule : IApiModule
|
||||
};
|
||||
}
|
||||
|
||||
// Formulaires des demandes en vigueur (référence privée, recopiée à l'identique dans le fichier public).
|
||||
FeedbackFormsSignatureState? feedbackForms = null;
|
||||
if (forms.PublishingEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var current = forms.Current;
|
||||
feedbackForms = current.Revision == 0
|
||||
? new FeedbackFormsSignatureState()
|
||||
: new FeedbackFormsSignatureState
|
||||
{
|
||||
Revision = current.Revision,
|
||||
Digest = FeedbackFormRules.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 des formulaires des demandes");
|
||||
feedbackForms = new FeedbackFormsSignatureState { Unreadable = true };
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
@@ -97,7 +149,9 @@ public sealed class SigningModule : IApiModule
|
||||
KeyringPublicUrl = keyrings.PublishingEnabled ? settings.KeyringPublicUrl : null,
|
||||
RequireSignatures = settings.ContentSigning.Require,
|
||||
Manifests = manifests,
|
||||
Catalog = catalog
|
||||
Catalog = catalog,
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,8 +286,11 @@ Limites :
|
||||
textes) ;
|
||||
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
|
||||
et au moins celui du dernier trousseau qu'il a vu ;
|
||||
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
|
||||
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
|
||||
* un appareil ajouté au trousseau, ou une autorisation ajoutée à un appareil, pendant qu'un launcher tourne est
|
||||
connu de ce launcher dès le contenu suivant signé par cet appareil (nouveau téléchargement du trousseau, au plus une
|
||||
fois par minute) ;
|
||||
* un launcher laissé ouvert retélécharge le trousseau toutes les 30 minutes au plus (à la vérification suivante) : un
|
||||
appareil retiré, ou une autorisation enlevée, n'y est plus accepté sans attendre son redémarrage.
|
||||
|
||||
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
|
||||
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
|
||||
@@ -454,6 +457,50 @@ joueur remplit le sien, le serveur répond 409 : le launcher 3.2 relit les formu
|
||||
inchangés. Chaque demande conserve le formulaire tel qu'à l'envoi : elle reste lisible si un champ est renommé ou
|
||||
supprimé ensuite. Historique des 20 dernières publications : `feedback-forms-history/` dans les données privées.
|
||||
|
||||
## 3 decies. Mode maintenance du launcher (PC et VPS)
|
||||
|
||||
Le menu d'administration › page **« Maintenance »** active ou désactive le mode maintenance de **tous les launchers**
|
||||
(tous les jeux) : fond et accents rouge / orange, « Maintenance en cours — merci de revenir plus tard » au centre avec,
|
||||
au choix, une précision (6 lignes, 300 caractères, **aucun lien**). Pendant la maintenance, les joueurs ne peuvent plus
|
||||
installer, mettre à jour, vérifier, réparer, localiser ni lancer un jeu, ni ouvrir les paramètres ou la fenêtre « Aide » :
|
||||
seuls la **désinstallation** et le menu d'administration restent disponibles. Le document est **signé** : un serveur
|
||||
compromis ne peut ni déclencher une fausse maintenance ni la prolonger chez les launchers en mode `require`.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue) et 3 quinquies (trousseau).
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : le fichier est écrit à
|
||||
côté du trousseau (`/srv/exovival/www/catalog/maintenance.json`, 644), dans le dossier déjà autorisé en écriture
|
||||
(`catalog.conf`, étape 3 bis).
|
||||
2. **nginx** : ajoute le bloc `location = /catalog/maintenance.json { … }` de `server/deploy/nginx-catalog.conf`, à côté
|
||||
de celui du trousseau (aucun cache : la fin de la maintenance doit être vue tout de suite), puis
|
||||
`sudo nginx -t && sudo systemctl reload nginx`. Aucun bloc à ajouter dans `nginx-api.conf` (petite requête).
|
||||
3. **Launcher 3.3** : publie-le (mise à jour signée habituelle) **avant** l'étape 4. Les launchers 3.2 acceptent le
|
||||
nouveau trousseau (autorisation inconnue d'eux, sans effet) ; les launchers antérieurs à la 3.2 le refusent et
|
||||
gardent le précédent, comme pour les formulaires. Les launchers antérieurs à la 3.3 ignorent la maintenance.
|
||||
4. **Permission et trousseau** :
|
||||
1. page **Groupes et permissions** : donne `maintenance.manage` (« Mode maintenance ») au groupe qui doit pouvoir
|
||||
l'activer (le groupe Propriétaire l'a d'office ; Administrateur l'a sur une installation neuve) ;
|
||||
2. signe un nouveau trousseau (étape 3 quinquies, point 3) : l'autorisation **« mode maintenance »** des appareils
|
||||
concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
|
||||
5. **Activer** depuis la page « Maintenance » (message facultatif, confirmation), **désactiver** de même. Les launchers
|
||||
relisent le fichier au démarrage (avant toute mise à jour automatique), puis toutes les 2 minutes (toutes les minutes
|
||||
pendant une maintenance) ; celui de l'administrateur qui publie bascule aussitôt. Un launcher resté ouvert pendant
|
||||
la re-signature du trousseau (point 4) prend la nouvelle autorisation en compte à la relecture suivante
|
||||
(launcher 3.3.1 et suivants ; en 3.3.0, il fallait le redémarrer).
|
||||
|
||||
Comportement des launchers :
|
||||
- **Hors ligne** ou fichier injoignable au démarrage : pas de maintenance (le jeu installé reste jouable hors ligne).
|
||||
Pendant que le launcher tourne, une coupure garde le dernier état vérifié.
|
||||
- Fichier non signé, mal signé, hors limites ou plus ancien que le dernier vu (`contentSigning` « require ») : ignoré.
|
||||
En « warn », il est appliqué et signalé dans le journal ; en « off », il est appliqué sans vérification.
|
||||
- Une opération déjà commencée (téléchargement…) se termine ; un jeu déjà lancé n'est pas fermé. Les mises à jour du
|
||||
**launcher lui-même** (signées par la clé racine) continuent : c'est le moyen de corriger un launcher défectueux.
|
||||
- C'est un **confort d'affichage**, pas une barrière de sécurité : un launcher modifié peut l'ignorer. Si les serveurs
|
||||
du jeu doivent refuser les connexions, c'est à eux de le faire.
|
||||
|
||||
Historique des 20 dernières publications : `maintenance-history/` dans les données privées. Audit : `maintenance.enable`,
|
||||
`maintenance.disable`, `maintenance.update` (message modifié pendant la maintenance).
|
||||
|
||||
## 4. Créer ta clé d'administrateur (PC)
|
||||
|
||||
Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères minimum). Il crée :
|
||||
@@ -584,11 +631,14 @@ recréer les administrateurs.
|
||||
│ et leurs captures <id>-<n>.png|jpg (sans métadonnées)
|
||||
├─ feedback-forms.json (+ .bak) formulaires des demandes publiés (référence de la révision)
|
||||
├─ feedback-forms-history/ 20 dernières révisions publiées
|
||||
├─ maintenance.json (+ .bak) mode maintenance publié (référence de la révision)
|
||||
├─ maintenance-history/ 20 dernières révisions publiées
|
||||
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
|
||||
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
|
||||
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
|
||||
├─ keyring.json trousseau publié (écrit par le service, 644)
|
||||
├─ feedback-forms.json formulaires des demandes signés (écrit par le service, 644)
|
||||
├─ maintenance.json mode maintenance signé (écrit par le service, 644)
|
||||
└─ images/ images du catalogue (envoyées en SFTP)
|
||||
/etc/systemd/system/launcher-api.service
|
||||
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -55,6 +55,12 @@ public sealed record SigningState
|
||||
|
||||
/// <summary>Catalogue publié (null : publication du catalogue non configurée).</summary>
|
||||
public CatalogSignatureState? Catalog { get; init; }
|
||||
|
||||
/// <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
|
||||
@@ -64,3 +70,38 @@ public sealed record CatalogSignatureState
|
||||
public ContentSignature? Signature { get; init; }
|
||||
public string? SignerName { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Signature des formulaires des demandes en vigueur (feedback-forms.json).</summary>
|
||||
public sealed record FeedbackFormsSignatureState
|
||||
{
|
||||
/// <summary>Révision en vigueur (0 : formulaires d'origine, intégrés au launcher, jamais publiés).</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Condensé du contenu signé (vide : formulaires d'origine, 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 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;
|
||||
}
|
||||
}
|
||||
@@ -50,9 +50,58 @@ public sealed partial class LauncherRelease
|
||||
/// </summary>
|
||||
public List<ChangelogEntry> Changelog { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Version PRIORITAIRE la plus récente publiée jusqu'à celle-ci incluse (ex. "3.4.0"), ou null s'il n'y en a pas.
|
||||
/// Un launcher plus ancien que cette version installe la mise à jour au démarrage MÊME SI le joueur a désactivé
|
||||
/// la mise à jour automatique (correctif critique). Le réglage du joueur (self-update.json) n'est jamais modifié :
|
||||
/// une fois le launcher à jour, il n'est plus concerné (effet ponctuel).
|
||||
/// Reprise d'une publication à l'autre par LauncherPublisher (voir <see cref="ResolvePriorityVersion"/>) : une
|
||||
/// version normale publiée après un correctif critique l'impose toujours aux launchers restés trop anciens.
|
||||
/// Fait partie du contenu SIGNÉ : un serveur compromis ne peut ni l'ajouter, ni la retirer, ni la modifier.
|
||||
/// Ignorée par les launchers antérieurs à 3.4.0 (propriété inconnue).
|
||||
/// </summary>
|
||||
public string? PriorityVersion { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Version ParsedVersion => VersionUtil.Parse(Version);
|
||||
|
||||
/// <summary>Cette version a elle-même été publiée comme prioritaire (option --priority).</summary>
|
||||
[JsonIgnore]
|
||||
public bool IsPriorityRelease => TryGetPriorityVersion(out var priority) && priority == ParsedVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Vrai si un launcher en version <paramref name="currentVersion"/> doit installer cette mise à jour même avec la
|
||||
/// mise à jour automatique désactivée : il est plus ancien que la dernière version prioritaire publiée.
|
||||
/// </summary>
|
||||
public bool IsPriorityFor(Version currentVersion) =>
|
||||
TryGetPriorityVersion(out var priority) && priority > currentVersion;
|
||||
|
||||
/// <summary>Version prioritaire lisible (null ou illisible : faux).</summary>
|
||||
public bool TryGetPriorityVersion(out Version priority)
|
||||
{
|
||||
priority = new Version(0, 0, 0);
|
||||
return !string.IsNullOrWhiteSpace(PriorityVersion) && VersionUtil.TryParse(PriorityVersion, out priority);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Version prioritaire à inscrire dans une nouvelle publication (LauncherPublisher) :
|
||||
/// <list type="bullet">
|
||||
/// <item><paramref name="markPriority"/> : la version publiée elle-même ;</item>
|
||||
/// <item><paramref name="clearPriority"/> : aucune (priorité publiée par erreur, par exemple) ;</item>
|
||||
/// <item>sinon : celle de la publication précédente (<paramref name="previous"/>, dont la signature a été
|
||||
/// VÉRIFIÉE par l'appelant), si elle n'est pas supérieure à la version publiée.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static string? ResolvePriorityVersion(Version published, bool markPriority, bool clearPriority, LauncherRelease? previous)
|
||||
{
|
||||
if (markPriority && clearPriority)
|
||||
throw new ArgumentException("Une publication ne peut pas à la fois être prioritaire et retirer la priorité.");
|
||||
if (markPriority) return VersionUtil.Display(published);
|
||||
if (clearPriority || previous is null || !previous.TryGetPriorityVersion(out var carried) || carried > published)
|
||||
return null;
|
||||
return VersionUtil.Display(carried);
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (Format != 1)
|
||||
@@ -67,6 +116,12 @@ public sealed partial class LauncherRelease
|
||||
throw new UpdateSecurityException("Description de mise à jour invalide : taille incorrecte.");
|
||||
if (!Sha256Regex().IsMatch(Sha256 ?? ""))
|
||||
throw new UpdateSecurityException("Description de mise à jour invalide : empreinte SHA-256 incorrecte.");
|
||||
if (string.IsNullOrWhiteSpace(PriorityVersion)) PriorityVersion = null;
|
||||
else if (!VersionUtil.TryParse(PriorityVersion, out var priority))
|
||||
throw new UpdateSecurityException($"Description de mise à jour invalide : version prioritaire « {PriorityVersion} » illisible.");
|
||||
else if (priority > ParsedVersion)
|
||||
throw new UpdateSecurityException(
|
||||
$"Description de mise à jour invalide : version prioritaire {PriorityVersion} supérieure à la version publiée ({Version}).");
|
||||
Notes ??= new List<string>();
|
||||
// Textes affichés tels quels dans l'interface : mêmes bornes et nettoyage que les notes du jeu.
|
||||
Changelog = ChangelogLimits.Normalize(Changelog);
|
||||
|
||||
@@ -10,6 +10,12 @@ public sealed record LauncherUpdateCheck(Version CurrentVersion, LauncherRelease
|
||||
public bool IsAvailable => Release is not null && DownloadUri is not null;
|
||||
public string CurrentDisplay => VersionUtil.Display(CurrentVersion);
|
||||
public string AvailableDisplay => Release is null ? "" : VersionUtil.Display(Release.ParsedVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Mise à jour PRIORITAIRE pour ce launcher (plus ancien que la dernière version prioritaire publiée) :
|
||||
/// installée au démarrage même si la mise à jour automatique est désactivée. Information signée.
|
||||
/// </summary>
|
||||
public bool IsPriority => IsAvailable && Release!.IsPriorityFor(CurrentVersion);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Catalog;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.Utils;
|
||||
@@ -40,8 +41,23 @@ public sealed class ContentSecurityException : Exception
|
||||
public sealed class ContentTrustService
|
||||
{
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Nouvel essai après un échec réseau (trousseau jamais obtenu pendant ce démarrage).</summary>
|
||||
public static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Nouveau téléchargement, au plus à ce rythme, quand un contenu est signé par une clé absente du trousseau ou non
|
||||
/// autorisée pour ce contenu : clé ajoutée ou autorisation donnée (ex. « maintenance ») pendant que le launcher tourne.
|
||||
/// Plus court que la relecture du mode maintenance, pour qu'une activation juste après la republication du trousseau
|
||||
/// soit vue à la relecture suivante.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan KeyChangeRetryDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Âge maximal du trousseau en mémoire : au-delà, il est retéléchargé à la vérification suivante (une clé retirée ou
|
||||
/// une autorisation enlevée est prise en compte sans redémarrer le launcher).
|
||||
/// </summary>
|
||||
public static readonly TimeSpan KeyringMaxAge = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
@@ -81,12 +97,15 @@ public sealed class ContentTrustService
|
||||
|
||||
/// <summary>
|
||||
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
|
||||
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
|
||||
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
|
||||
/// vu (cache), sinon null. Téléchargé au démarrage, puis de nouveau :
|
||||
/// • après un échec réseau (<see cref="RetryDelay"/>) ;
|
||||
/// • quand un contenu est signé par une clé absente ou non autorisée pour ce contenu (<see cref="KeyChangeRetryDelay"/>) ;
|
||||
/// • quand il a plus de <see cref="KeyringMaxAge"/> (launcher ouvert longtemps).
|
||||
/// Chaque nouveau trousseau passe par les mêmes contrôles (signature racine, numéro jamais en baisse).
|
||||
/// </summary>
|
||||
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
|
||||
|
||||
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
|
||||
private async Task<KeyringDocument?> GetKeyringAsync(bool keyChanged, CancellationToken ct)
|
||||
{
|
||||
Task<KeyringDocument?> task;
|
||||
lock (_lock)
|
||||
@@ -94,7 +113,8 @@ public sealed class ContentTrustService
|
||||
var elapsed = _time.GetUtcNow() - _lastAttempt;
|
||||
if (_keyringTask is null
|
||||
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|
||||
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
|
||||
|| (_keyringTask.IsCompleted && keyChanged && elapsed > KeyChangeRetryDelay)
|
||||
|| (_keyringTask.IsCompleted && elapsed > KeyringMaxAge))
|
||||
{
|
||||
_lastAttempt = _time.GetUtcNow();
|
||||
_keyringTask = FetchKeyringAsync();
|
||||
@@ -150,7 +170,7 @@ public sealed class ContentTrustService
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
|
||||
{
|
||||
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
|
||||
lock (_lock) _keyringFetched = true; // contenu refusé : nouvel essai seulement aux conditions de GetKeyringAsync
|
||||
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
|
||||
return cached;
|
||||
}
|
||||
@@ -210,6 +230,26 @@ public sealed class ContentTrustService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie le mode maintenance téléchargé (maintenance.json) : clé du trousseau autorisée « maintenance », signature
|
||||
/// valide, pas plus ancien que le dernier accepté (un serveur compromis ne peut pas resservir une ancienne maintenance
|
||||
/// légitime). Faux : à ignorer (état précédent conservé).
|
||||
/// </summary>
|
||||
public async Task<bool> VerifyMaintenanceAsync(MaintenanceDocument maintenance, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
try
|
||||
{
|
||||
await VerifyAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(maintenance), maintenance.Signature,
|
||||
$"Mode maintenance (révision {maintenance.Revision}, {MaintenanceRules.Describe(maintenance)})", ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (ContentSecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Revérifie les formulaires en cache, sans réseau, avec le dernier trousseau vérifié (fichier modifié sur le disque : ignoré).</summary>
|
||||
public bool VerifyCachedFeedbackForms(FeedbackFormsDocument forms)
|
||||
{
|
||||
@@ -253,8 +293,9 @@ public sealed class ContentTrustService
|
||||
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
|
||||
{
|
||||
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
|
||||
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
|
||||
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
|
||||
// Clé absente du trousseau, ou présente sans l'autorisation de ce contenu (clé ajoutée, ou autorisation donnée,
|
||||
// depuis le dernier téléchargement) : nouveau téléchargement du trousseau, limité dans le temps.
|
||||
if (keyring is not null && KeyMayBeNewer(keyring, kind, signature))
|
||||
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
|
||||
|
||||
string problem;
|
||||
@@ -301,6 +342,14 @@ public sealed class ContentTrustService
|
||||
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
|
||||
}
|
||||
|
||||
/// <summary>La signature vient d'une clé que ce trousseau ne connaît pas, ou n'autorise pas pour ce contenu.</summary>
|
||||
private static bool KeyMayBeNewer(KeyringDocument keyring, string kind, ContentSignature? signature)
|
||||
{
|
||||
if (signature?.KeyId is not { } keyId) return false;
|
||||
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
|
||||
return key?.Scopes is not { } scopes || !scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ État
|
||||
|
||||
private sealed class TrustState
|
||||
|
||||
@@ -58,6 +58,7 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
|
||||
new("files.", "Fichiers du serveur"),
|
||||
new("keyring.", "Signature (trousseau)"),
|
||||
new("feedback.", "Demandes des joueurs"),
|
||||
new("maintenance.", "Mode maintenance"),
|
||||
new("audit.", "Journal d'audit (consultations, exports, purges)"),
|
||||
new("security", "Refus de sécurité")
|
||||
};
|
||||
@@ -504,6 +505,9 @@ public static class AuditTexts
|
||||
"feedback.delete" => "Demande de joueur supprimée",
|
||||
"feedback.purge" => "Anciennes demandes supprimées (conservation)",
|
||||
"feedback.forms" => "Formulaires des demandes publiés",
|
||||
"maintenance.enable" => "Mode maintenance activé",
|
||||
"maintenance.disable" => "Mode maintenance terminé",
|
||||
"maintenance.update" => "Message de la maintenance modifié",
|
||||
"files.delete" => "Fichier du serveur supprimé",
|
||||
"files.prune" => "Dossier « objects » nettoyé",
|
||||
"request.signature" => "Requête signée refusée",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using GameLauncher.Contracts.Security;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Maintenance » : page d'activation et de désactivation du mode maintenance du launcher (commun à tous les
|
||||
/// jeux), avec un message facultatif pour les joueurs. Permission maintenance.manage ; publication signée (autorisation
|
||||
/// « maintenance » du trousseau, vérifiée par les launchers). L'état est aussi rappelé au tableau de bord.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceAdminModule : ILauncherModule
|
||||
{
|
||||
public string Id => "maintenance";
|
||||
|
||||
public string DisplayName => "Maintenance";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
MaintenancePanelViewModel.PanelId,
|
||||
"Maintenance",
|
||||
"Mode maintenance de tous les launchers : thème rouge / orange, message au centre, tout est bloqué chez les joueurs " +
|
||||
"sauf la désinstallation des jeux et ce menu.",
|
||||
"TrafficCone",
|
||||
AdminPermissions.MaintenanceManage,
|
||||
Order: 5,
|
||||
context => new MaintenancePanelViewModel(context)));
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Modules.Admin.Accounts;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Maintenance » (maintenance.manage) : état du mode maintenance de tous les launchers, activation (message
|
||||
/// facultatif, confirmation), modification du message pendant la maintenance, désactivation (confirmation).
|
||||
/// • Chaque publication est signée par la clé de ce PC (autorisation « maintenance » du trousseau) : avertissement si
|
||||
/// les launchers des joueurs ne l'accepteront pas (<see cref="AdminPanelViewModel.ConfirmPlayerSigningAsync"/>).
|
||||
/// • Message contrôlé ici avec les règles partagées (le serveur et les launchers les réappliquent) : texte brut, sans lien.
|
||||
/// • Après une publication, le launcher de ce PC relit aussitôt le mode maintenance (<see cref="IAdminContext.NotifyMaintenanceChanged"/>).
|
||||
/// </summary>
|
||||
public sealed class MaintenancePanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "maintenance";
|
||||
|
||||
private MaintenanceState? _state;
|
||||
private string _message = "";
|
||||
private string? _messageError;
|
||||
private string? _statusMessage;
|
||||
|
||||
public MaintenancePanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(() => RunAsync(ReloadAsync)), () => !IsBusy);
|
||||
EnableCommand = new AsyncRelayCommand(ScrollingToTop(EnableAsync), () => !IsBusy && CanPublish && !IsActive);
|
||||
UpdateMessageCommand = new AsyncRelayCommand(ScrollingToTop(UpdateMessageAsync), () => !IsBusy && CanPublish && IsActive && IsMessageChanged);
|
||||
DisableCommand = new AsyncRelayCommand(ScrollingToTop(DisableAsync), () => !IsBusy && CanPublish && IsActive);
|
||||
}
|
||||
|
||||
public AsyncRelayCommand RefreshCommand { get; }
|
||||
public AsyncRelayCommand EnableCommand { get; }
|
||||
public AsyncRelayCommand UpdateMessageCommand { get; }
|
||||
public AsyncRelayCommand DisableCommand { get; }
|
||||
|
||||
// ------------------------------------------------------------------ État publié
|
||||
|
||||
public bool IsLoaded => _state is not null;
|
||||
|
||||
/// <summary>Maintenance en cours (état publié).</summary>
|
||||
public bool IsActive => _state?.Document.Enabled == true;
|
||||
|
||||
/// <summary>Publication possible : état chargé et fichier public configuré sur le serveur.</summary>
|
||||
public bool CanPublish => _state is { PublishingEnabled: true };
|
||||
|
||||
public bool ShowPublishingDisabled => _state is { PublishingEnabled: false };
|
||||
|
||||
public string? PublicUrl => _state?.PublicUrl;
|
||||
|
||||
public string StateTitle => _state is null ? "Chargement…" : IsActive ? "Maintenance en cours" : "Launchers accessibles";
|
||||
|
||||
public string StateText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_state is not { } state) return "";
|
||||
var document = state.Document;
|
||||
var by = state.UpdatedBy is { } name ? $" par {name}" : "";
|
||||
if (document.Revision == 0) return "Le mode maintenance n'a jamais été activé.";
|
||||
if (document.Enabled)
|
||||
return (document.Since is { } since ? $"Depuis le {AccountTexts.Date(since)}" : "En cours") + by +
|
||||
(document.UpdatedAt is { } updated && document.Since is { } start && updated - start > TimeSpan.FromMinutes(1)
|
||||
? $" · message modifié le {AccountTexts.Date(updated)}"
|
||||
: "") + ".";
|
||||
return (document.UpdatedAt is { } at ? $"Dernière maintenance terminée le {AccountTexts.Date(at)}" : "Maintenance terminée") + by + ".";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Message publié (pendant la maintenance), sinon null.</summary>
|
||||
public string? PublishedMessage => IsActive ? _state!.Document.Message : null;
|
||||
|
||||
// ------------------------------------------------------------------ Saisie
|
||||
|
||||
/// <summary>Précision affichée aux joueurs sous « Maintenance en cours — merci de revenir plus tard » (facultative).</summary>
|
||||
public string Message
|
||||
{
|
||||
get => _message;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _message, value ?? "")) return;
|
||||
MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
MessageError = problem is null ? null : $"Message : {problem}";
|
||||
OnPropertyChanged(nameof(MessageCounter));
|
||||
OnPropertyChanged(nameof(PreviewMessage));
|
||||
OnPropertyChanged(nameof(IsMessageChanged));
|
||||
RefreshCommands();
|
||||
}
|
||||
}
|
||||
|
||||
public string? MessageError
|
||||
{
|
||||
get => _messageError;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _messageError, value)) OnPropertyChanged(nameof(HasMessageError));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasMessageError => _messageError is not null;
|
||||
|
||||
public string MessageCounter => $"{_message.Trim().Length} / {MaintenanceRules.MaxMessageLength}";
|
||||
|
||||
public int MaxMessageLength => MaintenanceRules.MaxMessageLength;
|
||||
|
||||
/// <summary>Le message saisi diffère de celui publié (bouton « Modifier le message »).</summary>
|
||||
public bool IsMessageChanged
|
||||
{
|
||||
get
|
||||
{
|
||||
var clean = MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
return problem is null && !string.Equals(clean, PublishedMessage, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Aperçu (textes affichés aux joueurs)
|
||||
|
||||
public string PreviewTitle => MaintenanceRules.Title;
|
||||
public string PreviewSubtitle => MaintenanceRules.Subtitle;
|
||||
|
||||
/// <summary>Message tel que les joueurs le verront (nettoyé), sinon null.</summary>
|
||||
public string? PreviewMessage => MaintenanceRules.CleanMessage(_message, out _);
|
||||
|
||||
// ------------------------------------------------------------------ Bandeau de résultat
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
protected override void OnBusyChanged() => RefreshCommands();
|
||||
|
||||
private void RefreshCommands()
|
||||
{
|
||||
RefreshCommand.RaiseCanExecuteChanged();
|
||||
EnableCommand.RaiseCanExecuteChanged();
|
||||
UpdateMessageCommand.RaiseCanExecuteChanged();
|
||||
DisableCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => RunAsync(ReloadAsync);
|
||||
|
||||
private async Task ReloadAsync() => Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: true);
|
||||
|
||||
private void Apply(MaintenanceState state, bool resetMessage)
|
||||
{
|
||||
_state = state;
|
||||
if (resetMessage) Message = state.Document.Enabled ? state.Document.Message ?? "" : _message;
|
||||
foreach (var name in new[]
|
||||
{
|
||||
nameof(IsLoaded), nameof(IsActive), nameof(CanPublish), nameof(ShowPublishingDisabled), nameof(PublicUrl),
|
||||
nameof(StateTitle), nameof(StateText), nameof(PublishedMessage), nameof(IsMessageChanged)
|
||||
})
|
||||
OnPropertyChanged(name);
|
||||
RefreshCommands();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Actions
|
||||
|
||||
/// <summary>Message nettoyé, ou null avec l'erreur affichée (sous le champ).</summary>
|
||||
private bool TryCleanMessage(out string? clean)
|
||||
{
|
||||
clean = MaintenanceRules.CleanMessage(_message, out var problem);
|
||||
MessageError = problem is null ? null : $"Message : {problem}";
|
||||
return problem is null;
|
||||
}
|
||||
|
||||
private async Task EnableAsync()
|
||||
{
|
||||
if (_state is not { } state) return;
|
||||
StatusMessage = null;
|
||||
if (!TryCleanMessage(out var message)) return;
|
||||
if (!Context.Dialogs.Confirm("Activer le mode maintenance ?",
|
||||
"Tous les launchers des joueurs passeront en mode maintenance au plus tard dans 2 minutes (ceux qui démarrent : " +
|
||||
"aussitôt), pour tous les jeux :\n\n" +
|
||||
"• installation, mises à jour, vérification, réparation et lancement des jeux bloqués ;\n" +
|
||||
"• paramètres du launcher et des jeux, bouton « Aide » et site officiel indisponibles ;\n" +
|
||||
"• seule la désinstallation reste possible, et ce menu d'administration.\n\n" +
|
||||
"Les téléchargements déjà commencés se terminent et les parties en cours ne sont pas fermées. Les mises à jour du " +
|
||||
"launcher lui-même continuent.",
|
||||
"Activer la maintenance", danger: true))
|
||||
return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Activer quand même")) return;
|
||||
await PublishAsync(enabled: true, message, since: null, state.Document.Revision,
|
||||
"Mode maintenance activé : les launchers des joueurs l'affichent au plus tard dans 2 minutes.");
|
||||
}
|
||||
|
||||
private async Task UpdateMessageAsync()
|
||||
{
|
||||
if (_state is not { Document: { Enabled: true } document }) return;
|
||||
StatusMessage = null;
|
||||
if (!TryCleanMessage(out var message)) return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Modifier quand même")) return;
|
||||
await PublishAsync(enabled: true, message, document.Since, document.Revision,
|
||||
"Message de la maintenance modifié : les launchers des joueurs l'affichent au plus tard dans une minute.");
|
||||
}
|
||||
|
||||
private async Task DisableAsync()
|
||||
{
|
||||
if (_state is not { Document: { Enabled: true } document }) return;
|
||||
StatusMessage = null;
|
||||
if (!Context.Dialogs.Confirm("Terminer la maintenance ?",
|
||||
"Les launchers des joueurs redeviendront utilisables au plus tard dans une minute (installation, mises à jour, " +
|
||||
"lancement des jeux) ; ils rechercheront aussitôt les mises à jour des jeux.",
|
||||
"Terminer la maintenance"))
|
||||
return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.Maintenance, "Terminer quand même")) return;
|
||||
await PublishAsync(enabled: false, message: null, since: null, document.Revision,
|
||||
"Maintenance terminée : les launchers des joueurs redeviennent utilisables au plus tard dans une minute.");
|
||||
}
|
||||
|
||||
private async Task PublishAsync(bool enabled, string? message, DateTimeOffset? since, long baseRevision, string success)
|
||||
{
|
||||
MaintenanceState? published = null;
|
||||
var ok = await RunAsync(async () =>
|
||||
published = await Context.Session.PublishMaintenanceAsync(enabled, message, since, baseRevision));
|
||||
if (!ok)
|
||||
{
|
||||
// Publication par quelqu'un d'autre entre-temps (409) : état relu, la saisie est gardée.
|
||||
if (ErrorMessage is not null) await ReloadKeepingMessageAsync();
|
||||
return;
|
||||
}
|
||||
Apply(published!, resetMessage: true);
|
||||
if (!enabled) Message = "";
|
||||
StatusMessage = success;
|
||||
Context.NotifyMaintenanceChanged(); // ce launcher bascule tout de suite
|
||||
}
|
||||
|
||||
private async Task ReloadKeepingMessageAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Apply(await Context.Session.GetMaintenanceAsync(), resetMessage: false);
|
||||
}
|
||||
catch (Exception ex) when (ex is ApiException or HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
// L'erreur de la publication reste affichée ; « Recharger » permet de réessayer.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ 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.
|
||||
/// proposé d'après les appareils actifs autorisés à publier, état des signatures du contenu en ligne (versions en
|
||||
/// 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>
|
||||
@@ -193,6 +194,29 @@ public sealed class SigningPanelViewModel : AdminPanelViewModel
|
||||
items.Add(new SignedContentItem($"Catalogue (révision {catalog.Revision})", ContentKinds.Catalog, ContentKinds.CatalogId,
|
||||
catalog.Digest, catalog.Signature, catalog.SignerName, state.Keyring, proposed,
|
||||
"Republiez-le depuis la page « Jeux » (il est signé à chaque publication)."));
|
||||
if (state.FeedbackForms is { } forms)
|
||||
items.Add(forms.Unreadable
|
||||
? SignedContentItem.Failed("Formulaires des demandes",
|
||||
"Fichier des formulaires illisible sur le serveur (voir ses journaux) : restaurez-le depuis feedback-forms-history/ " +
|
||||
"dans les données privées du serveur.")
|
||||
: new SignedContentItem(
|
||||
forms.Revision > 0 ? $"Formulaires des demandes (révision {forms.Revision})" : "Formulaires des demandes",
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -319,12 +343,12 @@ public sealed class KeyringKeyItem
|
||||
public sealed class SignedContentItem
|
||||
{
|
||||
internal SignedContentItem(string title, string kind, string id, string digest, ContentSignature? signature, string? signerName,
|
||||
KeyringDocument? published, KeyringDocument proposed, string howToSign)
|
||||
KeyringDocument? published, KeyringDocument proposed, string howToSign, string nothingPublished = "Rien n'est publié.")
|
||||
{
|
||||
Title = title;
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
Text = "Rien n'est publié.";
|
||||
Text = nothingPublished;
|
||||
IsValid = true;
|
||||
return;
|
||||
}
|
||||
@@ -342,6 +366,15 @@ public sealed class SignedContentItem
|
||||
Text += $" Attention : sa clé ne sera plus dans le trousseau proposé. {howToSign}";
|
||||
}
|
||||
|
||||
private SignedContentItem(string title, string text)
|
||||
{
|
||||
Title = title;
|
||||
Text = text;
|
||||
}
|
||||
|
||||
/// <summary>Contenu dont l'état ne peut pas être lu (refusé, à corriger sur le serveur).</summary>
|
||||
internal static SignedContentItem Failed(string title, string text) => new(title, text);
|
||||
|
||||
public string Title { get; }
|
||||
public string Text { get; }
|
||||
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -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
|
||||
@@ -822,7 +899,8 @@ public partial class App : Application, ILauncherRestartHost
|
||||
|
||||
/// <summary>
|
||||
/// Nouvelle version non installée automatiquement (option désactivée, échec précédent,
|
||||
/// dossier protégé) : simple information dans le bandeau.
|
||||
/// dossier protégé, « Plus tard ») : simple information dans le bandeau, en avertissement pour une
|
||||
/// version prioritaire.
|
||||
/// </summary>
|
||||
private async Task NotifyLauncherUpdateAsync()
|
||||
{
|
||||
@@ -830,7 +908,13 @@ public partial class App : Application, ILauncherRestartHost
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(4));
|
||||
var check = _updates!.SkippedUpdate ?? await _updates.CheckAsync();
|
||||
if (check.IsAvailable)
|
||||
if (check.IsPriority)
|
||||
// Correctif critique non installé au démarrage (« Plus tard », échec précédent, dossier protégé…)
|
||||
_launcher?.SelectedGame?.ShowNotice("Mise à jour importante du launcher",
|
||||
$"La version {check.AvailableDisplay} corrige un problème important et n'est pas encore installée : " +
|
||||
"Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
|
||||
DialogKind.Warning);
|
||||
else if (check.IsAvailable)
|
||||
_launcher?.SelectedGame?.ShowNotice("Nouvelle version du launcher",
|
||||
$"La version {check.AvailableDisplay} est disponible : Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
|
||||
DialogKind.Info);
|
||||
|
||||
@@ -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.0</Version>
|
||||
<Version>3.4.0</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
|
||||
@@ -128,12 +128,15 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
/// <summary>
|
||||
/// Au démarrage, avant l'affichage de la fenêtre principale : recherche rapide et, si une
|
||||
/// nouvelle version existe et que la mise à jour automatique est activée, installation.
|
||||
/// Mise à jour automatique désactivée : la recherche a lieu quand même, et seule une version
|
||||
/// PRIORITAIRE (correctif critique, information signée) est installée. Le réglage du joueur
|
||||
/// (self-update.json) n'est jamais modifié : il reste désactivé pour les versions suivantes.
|
||||
/// Renvoie vrai si le launcher redémarre (l'appelant doit alors s'arrêter là).
|
||||
/// </summary>
|
||||
public async Task<bool> RunStartupCheckAsync()
|
||||
{
|
||||
ExecutableSwapper.CleanupLeftovers(TargetPath, CurrentVersionValue);
|
||||
if (_client is null || !AutoUpdate || !IsSingleFileBuild) return false;
|
||||
if (_client is null || !IsSingleFileBuild) return false;
|
||||
|
||||
LauncherUpdateCheck check;
|
||||
try
|
||||
@@ -148,6 +151,18 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
}
|
||||
|
||||
if (!check.IsAvailable) return false;
|
||||
if (!AutoUpdate && !check.IsPriority)
|
||||
{
|
||||
// Option désactivée, version normale : simple information dans le bandeau (sans seconde recherche).
|
||||
SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
if (check.IsPriority)
|
||||
Log.Warn($"Launcher {check.AvailableDisplay} : mise à jour PRIORITAIRE (correctif publié en {check.Release!.PriorityVersion})" +
|
||||
(AutoUpdate ? "." : ", installée bien que la mise à jour automatique soit désactivée (réglage conservé)."));
|
||||
|
||||
// Échec précédent : même prioritaire, pas de nouvelle tentative automatique (une version qui ne démarre pas
|
||||
// sur ce PC imposerait 60 s d'attente et un retour arrière à chaque lancement). Bandeau + bouton manuel.
|
||||
if (string.Equals(_settings.FailedVersion, check.AvailableDisplay, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Warn($"Launcher {check.AvailableDisplay} : installation automatique ignorée (échec précédent). " +
|
||||
@@ -167,7 +182,10 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
return await InstallAsync(check, automatic: true);
|
||||
if (await InstallAsync(check, automatic: true)) return true;
|
||||
// Version prioritaire non installée (« Plus tard », erreur) : rappel dans le bandeau une fois la fenêtre affichée.
|
||||
if (check.IsPriority) SkippedUpdate = check;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Installation
|
||||
@@ -265,10 +283,17 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
|
||||
Log.Error("Mise à jour du launcher impossible", ex);
|
||||
// Au démarrage, une erreur persistante (fichier absent du serveur, antivirus…) ne doit
|
||||
// pas réapparaître à chaque lancement : cette version ne sera plus installée automatiquement.
|
||||
if (automatic) RememberFailure(update);
|
||||
vm.SetError(FriendlyMessage(ex) + (automatic
|
||||
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
|
||||
: ""));
|
||||
// Exception : une version PRIORITAIRE (correctif critique) est retentée au prochain démarrage
|
||||
// (erreur de téléchargement le plus souvent passagère). Seul un échec de démarrage de la nouvelle
|
||||
// version (plus haut, après retour arrière) l'écarte des installations automatiques.
|
||||
var retryAtNextStart = automatic && update.IsPriority;
|
||||
if (automatic && !retryAtNextStart) RememberFailure(update);
|
||||
vm.SetError(FriendlyMessage(ex) + (retryAtNextStart
|
||||
? "\n\nCette mise à jour est importante : elle sera retentée au prochain démarrage du launcher " +
|
||||
"(ou dès maintenant depuis Paramètres du launcher › Vérifier les mises à jour)."
|
||||
: automatic
|
||||
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
|
||||
: ""));
|
||||
}
|
||||
|
||||
// Erreur : la fenêtre reste ouverte jusqu'à ce que l'utilisateur la ferme.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.<clé>" 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(
|
||||
|
||||
@@ -116,9 +116,19 @@ public sealed class LauncherSettingsViewModel : SettingsViewModelBase
|
||||
if (check.IsAvailable)
|
||||
{
|
||||
SetAvailable(check);
|
||||
LauncherStatusKind = DialogKind.Info;
|
||||
var title = string.IsNullOrWhiteSpace(check.Release?.Title) ? "" : $" — {check.Release!.Title}";
|
||||
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
|
||||
if (check.IsPriority)
|
||||
{
|
||||
// Correctif critique non installé au démarrage (annulé, échec, dossier protégé…)
|
||||
LauncherStatusKind = DialogKind.Warning;
|
||||
LauncherStatus = $"Mise à jour importante {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}). " +
|
||||
"Installez-la dès que possible.";
|
||||
}
|
||||
else
|
||||
{
|
||||
LauncherStatusKind = DialogKind.Info;
|
||||
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -21,13 +21,19 @@ public sealed class LauncherUpdateViewModel : ObservableObject
|
||||
public LauncherUpdateViewModel(LauncherUpdateCheck update, bool automatic)
|
||||
{
|
||||
Automatic = automatic;
|
||||
IsPriority = update.IsPriority;
|
||||
FromVersion = update.CurrentDisplay;
|
||||
ToVersion = update.AvailableDisplay;
|
||||
// Notes de toutes les versions publiées depuis la version installée (format enrichi)
|
||||
Changelog = update.Release?.GetChangelogSince(update.CurrentVersion) ?? Array.Empty<ChangelogEntry>();
|
||||
Subtitle = automatic
|
||||
? "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul."
|
||||
: "Installation de la nouvelle version. Le launcher redémarrera tout seul.";
|
||||
Subtitle = (automatic, IsPriority) switch
|
||||
{
|
||||
(true, true) => "Mise à jour importante : elle s'installe automatiquement, même si la mise à jour automatique " +
|
||||
"du launcher est désactivée (votre réglage ne change pas). Le launcher redémarrera tout seul.",
|
||||
(true, false) => "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul.",
|
||||
(false, true) => "Installation d'une mise à jour importante. Le launcher redémarrera tout seul.",
|
||||
_ => "Installation de la nouvelle version. Le launcher redémarrera tout seul."
|
||||
};
|
||||
|
||||
Progress = new Progress<OperationProgress>(p =>
|
||||
{
|
||||
@@ -44,6 +50,9 @@ public sealed class LauncherUpdateViewModel : ObservableObject
|
||||
/// <summary>Recherche au démarrage (vrai) ou demandée dans les Paramètres du launcher (faux).</summary>
|
||||
public bool Automatic { get; }
|
||||
|
||||
/// <summary>Mise à jour prioritaire (correctif critique, information signée) : pastille « Prioritaire ».</summary>
|
||||
public bool IsPriority { get; }
|
||||
|
||||
public string FromVersion { get; }
|
||||
public string ToVersion { get; }
|
||||
public string Subtitle { get; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -76,6 +76,16 @@
|
||||
<TextBlock Text="{Binding ToVersion}" FontSize="13" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Mise à jour prioritaire (correctif critique) : installée même si l'option automatique est désactivée -->
|
||||
<Border Style="{StaticResource VersionChipStyle}" Margin="10,0,0,0" BorderBrush="{DynamicResource WarningBrush}"
|
||||
ToolTip="Correctif important : installé même si la mise à jour automatique du launcher est désactivée. Votre réglage ne change pas."
|
||||
Visibility="{Binding IsPriority, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Data="{StaticResource Icon.Alert}" Fill="{DynamicResource WarningBrush}"
|
||||
Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,7,0" />
|
||||
<TextBlock Text="Prioritaire" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Nouveautés : même présentation que les notes du jeu (Themes/Changelog.xaml) -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Publishing;
|
||||
using GameLauncher.Modules.Admin.Signing;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Tests.Contracts;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Publishing;
|
||||
using GameLauncher.Tests.Server;
|
||||
@@ -91,6 +92,71 @@ public class SigningPanelTests
|
||||
Assert.Contains("déjà publié", page.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_feedback_forms_signature_is_shown_below_the_catalog()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-signing-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"),
|
||||
["LauncherApi:Games:EZR:ManifestFile"] = ChangelogModuleTests.PublishedManifest(),
|
||||
["LauncherApi:Feedback:Enabled"] = "true",
|
||||
["LauncherApi:Feedback:Games:0"] = "EZR"
|
||||
});
|
||||
using var folder = new TempFolder();
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
|
||||
using var _ = signer;
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
using var root = SigningKit.NewKey();
|
||||
|
||||
// Trousseau n° 1 : la clé de Camille, sans l'autorisation « formulaires ».
|
||||
using var camilleKey = api.AddOwner("Camille");
|
||||
await using var camille = await api.LoginAsync(camilleKey);
|
||||
KeyringKey Entry(params string[] scopes) => new()
|
||||
{
|
||||
KeyId = camilleKey.KeyId, PublicKey = Convert.ToBase64String(camilleKey.PublicKey), Name = "Camille — PC", Scopes = scopes.ToList()
|
||||
};
|
||||
await camille.PublishKeyringAsync(SigningKit.Keyring(root, 1, Entry(SigningScopes.All.Where(s => s != SigningScopes.Forms).ToArray())));
|
||||
|
||||
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), Modules,
|
||||
rootKeys: new[] { SigningKit.Root(root) }));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(SigningPanelViewModel.PanelId);
|
||||
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, 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[^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);
|
||||
|
||||
// Publiés par Camille, dont la clé n'a pas l'autorisation « formulaires » dans le trousseau publié : refusés par
|
||||
// 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[^2];
|
||||
Assert.Equal("Formulaires des demandes (révision 1)", forms.Title);
|
||||
Assert.False(forms.IsValid);
|
||||
Assert.False(forms.WillBreak);
|
||||
Assert.Contains("refusé par les launchers", forms.Text);
|
||||
Assert.Contains("« Formulaires des demandes »", forms.Text);
|
||||
|
||||
// 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[^2];
|
||||
Assert.True(forms.IsValid, forms.Text);
|
||||
Assert.Contains("Camille", forms.Text);
|
||||
Assert.Contains("valide pour les launchers", forms.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_names_proposed_for_the_public_keyring_are_cleaned()
|
||||
{
|
||||
|
||||
@@ -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("Texteesrevni", out problem); // inversion du sens d'écriture
|
||||
Assert.NotNull(problem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Documents_are_validated_and_signed_on_a_canonical_digest()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
Assert.True(MaintenanceRules.Validate(Active(message: "Mise à jour des serveurs."), errors), string.Join(" ", errors));
|
||||
Assert.True(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false }, errors));
|
||||
|
||||
Assert.False(MaintenanceRules.Validate(Active() with { Revision = 0 }, errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(Active() with { Since = null }, errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(Active() with { Since = Since.AddMilliseconds(500) }, errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(Active(message: " non nettoyé "), errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(Active(message: "Voir www.site.com"), errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false, Message = "x" }, errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false, Since = Since }, errors = new()));
|
||||
Assert.False(MaintenanceRules.Validate(Active() with { Format = "autre" }, errors = new()));
|
||||
// Début dans le futur (horloge du PC de l'administrateur déréglée) : refusé par le serveur.
|
||||
Assert.False(MaintenanceRules.Validate(Active(), errors = new(), now: Since - TimeSpan.FromHours(1)));
|
||||
|
||||
// Condensé : état, message, début et révision couverts ; date de publication et signature non.
|
||||
var digest = MaintenanceRules.Digest(Active(message: "A"));
|
||||
Assert.Equal(digest, MaintenanceRules.Digest(Active(message: "A") with { UpdatedAt = DateTimeOffset.UtcNow }));
|
||||
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(message: "B")));
|
||||
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(2, "A")));
|
||||
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(message: "A") with { Since = Since.AddSeconds(1) }));
|
||||
Assert.NotEqual(MaintenanceRules.Digest(Active()), MaintenanceRules.Digest(new MaintenanceDocument { Revision = 1 }));
|
||||
|
||||
// Aller-retour du fichier public.
|
||||
var parsed = MaintenanceRules.Parse(System.Text.Encoding.UTF8.GetBytes(MaintenanceRules.Serialize(Active(message: "A"))))!;
|
||||
Assert.Equal(digest, MaintenanceRules.Digest(parsed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Maintenance_has_its_own_permission_and_keyring_scope()
|
||||
{
|
||||
Assert.Contains(AdminPermissions.MaintenanceManage, AdminPermissions.All);
|
||||
Assert.Equal("Mode maintenance", AdminPermissions.BuiltInLabel(AdminPermissions.MaintenanceManage));
|
||||
Assert.True(AdminRoles.Has(AdminRoles.Owner, AdminPermissions.MaintenanceManage));
|
||||
Assert.True(AdminRoles.Has(AdminRoles.Admin, AdminPermissions.MaintenanceManage));
|
||||
Assert.False(AdminRoles.Has(AdminRoles.Editor, AdminPermissions.MaintenanceManage));
|
||||
|
||||
Assert.Equal(SigningScopes.Maintenance, ContentKinds.ScopeOf(ContentKinds.Maintenance));
|
||||
Assert.Equal(ContentKinds.MaintenanceId, ContentSigning.SignedId(ContentKinds.Maintenance, "autre"));
|
||||
Assert.Equal(new[] { SigningScopes.Maintenance }, SigningScopes.FromPermissions(new[] { AdminPermissions.MaintenanceManage }));
|
||||
Assert.Equal("mode maintenance", SigningScopes.DisplayName(SigningScopes.Maintenance));
|
||||
|
||||
// Une clé autorisée « catalog » seulement ne peut pas signer le mode maintenance.
|
||||
using var key = Signing.SigningKit.NewKey();
|
||||
using var root = Signing.SigningKit.NewKey();
|
||||
var document = Active();
|
||||
var signature = Signing.SigningKit.Sign(key, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document));
|
||||
var catalogOnly = Signing.SigningKit.Keyring(root, 1, Signing.SigningKit.Entry(key, "Léo", SigningScopes.Catalog));
|
||||
Assert.False(ContentSigning.Verify(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signature, catalogOnly).Valid);
|
||||
var allowed = Signing.SigningKit.Keyring(root, 2, Signing.SigningKit.Entry(key, "Nicolas", SigningScopes.Maintenance));
|
||||
Assert.True(ContentSigning.Verify(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signature, allowed).Valid);
|
||||
// Signature du mode maintenance réutilisée pour un autre contenu : refusée (séparation des types).
|
||||
Assert.False(ContentSigning.VerifyWithKey(ContentKinds.Catalog, ContentKinds.CatalogId, MaintenanceRules.Digest(document), signature,
|
||||
key.ExportSubjectPublicKeyInfo()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Net;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Storage;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Signing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GameLauncher.Tests.Server;
|
||||
|
||||
/// <summary>Mode maintenance : publication signée depuis le menu, fichier public, droits, révisions, audit.</summary>
|
||||
public class MaintenanceModuleTests
|
||||
{
|
||||
private static async Task<(TestApi Api, string PublicDirectory)> StartAsync()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-maintenance-tests", Guid.NewGuid().ToString("N"), "catalog");
|
||||
Directory.CreateDirectory(directory);
|
||||
var api = await TestApi.StartAsync(new Dictionary<string, string?>
|
||||
{
|
||||
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json")
|
||||
});
|
||||
return (api, directory);
|
||||
}
|
||||
|
||||
private static MaintenanceDocument ReadPublic(string directory) =>
|
||||
MaintenanceRules.Parse(File.ReadAllBytes(Path.Combine(directory, "maintenance.json")))!;
|
||||
|
||||
[Fact]
|
||||
public async Task Maintenance_is_enabled_updated_and_disabled_with_signed_public_files_and_audit()
|
||||
{
|
||||
var (api, directory) = await StartAsync();
|
||||
await using var _ = api;
|
||||
using var key = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(key);
|
||||
|
||||
var state = await owner.GetMaintenanceAsync();
|
||||
Assert.Equal(0, state.Document.Revision);
|
||||
Assert.False(state.Document.Enabled);
|
||||
Assert.True(state.PublishingEnabled);
|
||||
Assert.EndsWith("/catalog/maintenance.json", state.PublicUrl);
|
||||
Assert.False(File.Exists(Path.Combine(directory, "maintenance.json")));
|
||||
|
||||
// Activation : fichier public signé par la clé de la session, vérifiable par les launchers.
|
||||
state = await owner.PublishMaintenanceAsync(true, "Mise à jour des serveurs.\nRetour vers 18 h.", null, 0);
|
||||
Assert.Equal(1, state.Document.Revision);
|
||||
Assert.True(state.Document.Enabled);
|
||||
Assert.NotNull(state.Document.Since);
|
||||
Assert.NotNull(state.UpdatedBy);
|
||||
var published = ReadPublic(directory);
|
||||
Assert.True(published.Enabled);
|
||||
Assert.Equal("Mise à jour des serveurs.\nRetour vers 18 h.", published.Message);
|
||||
Assert.True(ContentSigning.VerifyWithKey(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(published),
|
||||
published.Signature!, key.PublicKey));
|
||||
|
||||
// Nouveau message pendant la maintenance : même début.
|
||||
var since = state.Document.Since;
|
||||
state = await owner.PublishMaintenanceAsync(true, "Retour vers 19 h.", since, 1);
|
||||
Assert.Equal(since, ReadPublic(directory).Since);
|
||||
Assert.Equal("Retour vers 19 h.", ReadPublic(directory).Message);
|
||||
|
||||
// Fin : ni message ni début.
|
||||
state = await owner.PublishMaintenanceAsync(false, "ignoré", null, 2);
|
||||
published = ReadPublic(directory);
|
||||
Assert.False(published.Enabled);
|
||||
Assert.Null(published.Message);
|
||||
Assert.Null(published.Since);
|
||||
Assert.Equal(3, published.Revision);
|
||||
|
||||
var actions = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries
|
||||
.Where(e => e.Action.StartsWith("maintenance.", StringComparison.Ordinal)).Select(e => e.Action).Reverse().ToList();
|
||||
Assert.Equal(new[] { "maintenance.enable", "maintenance.update", "maintenance.disable" }, actions);
|
||||
var enable = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "maintenance.enable");
|
||||
Assert.Contains("Retour vers 18 h.", enable.Details);
|
||||
Assert.Contains(key.KeyId, enable.Details);
|
||||
|
||||
// Page « Signature » : signature du mode maintenance en vigueur.
|
||||
var signing = await owner.GetSigningStateAsync();
|
||||
Assert.Equal(3, signing.Maintenance!.Revision);
|
||||
Assert.False(signing.Maintenance.Enabled);
|
||||
Assert.Equal(MaintenanceRules.Digest(published), signing.Maintenance.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publication_needs_the_permission_the_session_key_the_current_revision_and_valid_content()
|
||||
{
|
||||
var (api, _) = await StartAsync();
|
||||
await using var __ = api;
|
||||
using var key = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(key);
|
||||
|
||||
// Éditeur (sans maintenance.manage) : lecture permise (tableau de bord), publication refusée.
|
||||
var (editorKey, _) = await api.AddAdminAsync(owner, "Léo", AdminRoles.Editor);
|
||||
await using (var editor = await api.LoginAsync(editorKey))
|
||||
{
|
||||
var denied = await Assert.ThrowsAsync<ApiException>(() => editor.PublishMaintenanceAsync(true, null, null, 0));
|
||||
Assert.Equal(403, denied.Status);
|
||||
Assert.False((await editor.GetMaintenanceAsync()).Document.Enabled);
|
||||
}
|
||||
editorKey.Dispose();
|
||||
|
||||
// Sans session : refusé.
|
||||
using (var anonymous = await api.Http.GetAsync(ApiRoutes.Prefix + "/" + ApiRoutes.Maintenance))
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||
|
||||
var state = await owner.PublishMaintenanceAsync(true, null, null, 0);
|
||||
// Révision dépassée : 409, rien n'est écrasé.
|
||||
var conflict = await Assert.ThrowsAsync<ApiException>(() => owner.PublishMaintenanceAsync(false, null, null, 0));
|
||||
Assert.Equal(409, conflict.Status);
|
||||
Assert.Equal(ApiErrorCodes.RevisionConflict, conflict.Code);
|
||||
Assert.True((await owner.GetMaintenanceAsync()).Document.Enabled);
|
||||
|
||||
var login = await api.RawLoginAsync(key);
|
||||
var since = state.Document.Since;
|
||||
|
||||
// Document signé par une autre clé que celle de la session, ou non signé : refusé.
|
||||
using var stranger = SigningKit.NewKey();
|
||||
var next = new MaintenanceDocument { Revision = 2, Enabled = true, Message = "Autre", Since = since };
|
||||
foreach (var document in new[]
|
||||
{
|
||||
next with { Signature = SigningKit.Sign(stranger, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(next)) },
|
||||
next
|
||||
})
|
||||
{
|
||||
using var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.Maintenance,
|
||||
new MaintenancePublishRequest { BaseRevision = 1, Document = document });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
// Signature de la session, mais contenu hors règles (lien), ou début modifié pendant la maintenance : refusé.
|
||||
foreach (var document in new[] { next with { Message = "Infos sur discord.gg/exovival" }, next with { Since = since!.Value.AddHours(-1) } })
|
||||
{
|
||||
var signature = await owner.SignContentAsync(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document));
|
||||
using var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.Maintenance,
|
||||
new MaintenancePublishRequest { BaseRevision = 1, Document = document with { Signature = signature } });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
// Signature d'un autre type de contenu (catalogue) sur le même condensé : refusée.
|
||||
var mixed = await owner.SignContentAsync(ContentKinds.Catalog, ContentKinds.CatalogId, MaintenanceRules.Digest(next));
|
||||
using (var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.Maintenance,
|
||||
new MaintenancePublishRequest { BaseRevision = 1, Document = next with { Signature = mixed } }))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
// Le client refuse aussi d'envoyer un message hors règles.
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => owner.PublishMaintenanceAsync(true, "Voir www.site.com", since, 1));
|
||||
Assert.Equal(1, (await owner.GetMaintenanceAsync()).Document.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_public_folder_publication_is_not_configured()
|
||||
{
|
||||
await using var api = await TestApi.StartAsync();
|
||||
using var key = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(key);
|
||||
|
||||
var state = await owner.GetMaintenanceAsync();
|
||||
Assert.False(state.PublishingEnabled);
|
||||
var refused = await Assert.ThrowsAsync<ApiException>(() => owner.PublishMaintenanceAsync(true, null, null, 0));
|
||||
Assert.Equal(503, refused.Status);
|
||||
Assert.Equal(ApiErrorCodes.NotConfigured, refused.Code);
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,74 @@ public class ContentTrustTests
|
||||
Assert.Equal(2, server.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scope_granted_while_the_launcher_runs_is_found_by_downloading_the_keyring_again()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var nicolas = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas", SigningScopes.Releases)));
|
||||
var clock = new ManualClock();
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
|
||||
var catalog = new CatalogDocument { Revision = 1, Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Texte" } } };
|
||||
catalog = catalog with
|
||||
{
|
||||
Signature = SigningKit.Sign(nicolas, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog))
|
||||
};
|
||||
Assert.False(await trust.VerifyCatalogAsync(catalog)); // clé connue, sans l'autorisation « catalog »
|
||||
Assert.Equal(1, server.Requests);
|
||||
|
||||
// Trousseau republié avec l'autorisation (ex. « maintenance » après la mise à jour 3.3) : vu sans redémarrer,
|
||||
// au plus une fois par KeyChangeRetryDelay.
|
||||
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog)));
|
||||
Assert.False(await trust.VerifyCatalogAsync(catalog));
|
||||
Assert.Equal(1, server.Requests);
|
||||
clock.Now += ContentTrustService.KeyChangeRetryDelay + TimeSpan.FromSeconds(1);
|
||||
Assert.True(await trust.VerifyCatalogAsync(catalog));
|
||||
Assert.Equal(2, server.Requests);
|
||||
Assert.Equal(2, trust.CachedKeyring!.Sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_launcher_left_open_downloads_the_keyring_again_and_stops_trusting_a_removed_key()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var nicolas = SigningKit.NewKey();
|
||||
using var compromised = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(compromised, "PC volé")));
|
||||
var clock = new ManualClock();
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
|
||||
await trust.VerifyManifestAsync(Manifest("1.2", compromised), "EZR");
|
||||
|
||||
// Clé retirée du trousseau (n° 2) : encore acceptée tant que le trousseau en mémoire est récent…
|
||||
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas")));
|
||||
clock.Now += TimeSpan.FromMinutes(10);
|
||||
await trust.VerifyManifestAsync(Manifest("1.3", compromised), "EZR");
|
||||
Assert.Equal(1, server.Requests);
|
||||
|
||||
// … puis refusée dès qu'il a dépassé son âge maximal (sans redémarrer le launcher).
|
||||
clock.Now += ContentTrustService.KeyringMaxAge;
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.4", compromised), "EZR"));
|
||||
Assert.Equal(2, server.Requests);
|
||||
await trust.VerifyManifestAsync(Manifest("1.4", nicolas), "EZR");
|
||||
|
||||
// Serveur injoignable au téléchargement suivant : dernier trousseau vu (n° 2) conservé, pas d'erreur.
|
||||
server.Keyring = null;
|
||||
clock.Now += ContentTrustService.KeyringMaxAge + TimeSpan.FromMinutes(1);
|
||||
await trust.VerifyManifestAsync(Manifest("1.5", nicolas), "EZR");
|
||||
Assert.Equal(3, server.Requests);
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.6", compromised), "EZR"));
|
||||
|
||||
// Serveur compromis qui ressert l'ancien trousseau n° 1 : refusé (retour en arrière), la clé reste exclue.
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(compromised, "PC volé")));
|
||||
clock.Now += ContentTrustService.KeyringMaxAge + TimeSpan.FromMinutes(1);
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.7", compromised), "EZR"));
|
||||
Assert.Equal(2, trust.CachedKeyring!.Sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_signature_dated_far_in_the_future_does_not_block_later_releases()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Tests.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Version PRIORITAIRE du launcher (correctif critique) : installée au démarrage même si le joueur a désactivé la mise
|
||||
/// à jour automatique. Information signée, reprise d'une publication à l'autre, sans effet sur les launchers à jour.
|
||||
/// </summary>
|
||||
public class LauncherPriorityTests
|
||||
{
|
||||
private static LauncherRelease Release(string version, string? priority = null) => new()
|
||||
{
|
||||
Product = "GameLauncher",
|
||||
Version = version,
|
||||
File = $"GameLauncher-{version}.exe",
|
||||
Size = 1234,
|
||||
Sha256 = new string('a', 64),
|
||||
PublishedAt = DateTimeOffset.UnixEpoch,
|
||||
PriorityVersion = priority
|
||||
};
|
||||
|
||||
private static (string Envelope, UpdateSignature.TrustedKey Key) Sign(LauncherRelease release, ECDsa key)
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options);
|
||||
var envelope = UpdateSignature.CreateEnvelope(payload, key);
|
||||
var spki = key.ExportSubjectPublicKeyInfo();
|
||||
return (envelope, new UpdateSignature.TrustedKey(UpdateSignature.ComputeKeyId(spki), spki));
|
||||
}
|
||||
|
||||
private static LauncherUpdateCheck Check(string current, LauncherRelease? release) =>
|
||||
new(VersionUtil.Parse(current), release, release is null ? null : new Uri("https://jeux.exemple.test/launcher/" + release.File));
|
||||
|
||||
[Fact]
|
||||
public void The_priority_version_is_part_of_the_signed_content()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var (envelope, trusted) = Sign(Release("3.4.0", priority: "3.4.0"), key);
|
||||
|
||||
var read = UpdateSignature.ReadRelease(envelope, new[] { trusted });
|
||||
Assert.Equal("3.4.0", read.PriorityVersion);
|
||||
Assert.True(read.IsPriorityRelease);
|
||||
|
||||
// Serveur compromis : retirer (ou ajouter) la priorité dans le payload casse la signature.
|
||||
var json = JsonSerializer.Deserialize<SignedEnvelope>(envelope, JsonDefaults.Options)!;
|
||||
var tampered = Encoding.UTF8.GetString(Convert.FromBase64String(json.Payload)).Replace("\"3.4.0\"", "\"3.3.9\"");
|
||||
json.Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(tampered));
|
||||
var forged = JsonSerializer.Serialize(json, JsonDefaults.Options);
|
||||
Assert.Throws<UpdateSecurityException>(() => UpdateSignature.ReadRelease(forged, new[] { trusted }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_release_without_priority_stays_compatible_with_older_launchers()
|
||||
{
|
||||
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var release = Release("3.4.0");
|
||||
var payload = Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options));
|
||||
Assert.DoesNotContain("priorityVersion", payload, StringComparison.OrdinalIgnoreCase); // champ absent : rien ne change
|
||||
|
||||
var (envelope, trusted) = Sign(release, key);
|
||||
var read = UpdateSignature.ReadRelease(envelope, new[] { trusted });
|
||||
Assert.Null(read.PriorityVersion);
|
||||
Assert.False(read.IsPriorityRelease);
|
||||
Assert.False(Check("3.3.1", read).IsPriority);
|
||||
Assert.True(Check("3.3.1", read).IsAvailable);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("3.3.1", true)] // plus ancien que le correctif : mise à jour imposée
|
||||
[InlineData("3.3.9", true)]
|
||||
[InlineData("3.4.0", false)] // correctif déjà installé : réglage du joueur respecté pour 3.5.0
|
||||
[InlineData("3.4.2", false)]
|
||||
public void Only_launchers_older_than_the_priority_version_are_forced(string current, bool forced)
|
||||
{
|
||||
var release = Release("3.5.0", priority: "3.4.0"); // 3.4.0 prioritaire, puis 3.5.0 normale
|
||||
Assert.Equal(forced, release.IsPriorityFor(VersionUtil.Parse(current)));
|
||||
Assert.Equal(forced, Check(current, release).IsPriority);
|
||||
Assert.False(release.IsPriorityRelease);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_update_available_means_no_priority()
|
||||
{
|
||||
Assert.False(Check("3.4.0", null).IsPriority);
|
||||
Assert.False(new LauncherUpdateCheck(VersionUtil.Parse("3.3.0"), Release("3.4.0", "3.4.0"), null).IsPriority);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("3.6.0")] // supérieure à la version publiée
|
||||
[InlineData("pas-une-version")]
|
||||
public void An_inconsistent_priority_version_is_refused(string priority)
|
||||
{
|
||||
var release = Release("3.5.0", priority);
|
||||
Assert.Throws<UpdateSecurityException>(release.Validate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_blank_priority_version_means_none()
|
||||
{
|
||||
var release = Release("3.5.0", " ");
|
||||
release.Validate();
|
||||
Assert.Null(release.PriorityVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_publisher_marks_carries_over_or_clears_the_priority()
|
||||
{
|
||||
var v350 = VersionUtil.Parse("3.5.0");
|
||||
var previousPriority = Release("3.4.0", priority: "3.4.0");
|
||||
var previousNormal = Release("3.4.0");
|
||||
|
||||
// --priority : la version publiée elle-même
|
||||
Assert.Equal("3.5.0", LauncherRelease.ResolvePriorityVersion(v350, markPriority: true, clearPriority: false, previousNormal));
|
||||
// version normale après un correctif critique : priorité reprise (les launchers < 3.4.0 restent forcés)
|
||||
Assert.Equal("3.4.0", LauncherRelease.ResolvePriorityVersion(v350, false, false, previousPriority));
|
||||
// --clear-priority : plus aucune version imposée
|
||||
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, true, previousPriority));
|
||||
// rien à reprendre
|
||||
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, false, previousNormal));
|
||||
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, false, null));
|
||||
// priorité précédente supérieure à la version publiée (--force vers une version inférieure) : non reprise
|
||||
Assert.Null(LauncherRelease.ResolvePriorityVersion(VersionUtil.Parse("3.3.5"), false, false, previousPriority));
|
||||
// options contradictoires
|
||||
Assert.Throws<ArgumentException>(() => LauncherRelease.ResolvePriorityVersion(v350, true, true, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_player_setting_file_has_no_priority_field()
|
||||
{
|
||||
// Effet ponctuel : la priorité vient UNIQUEMENT de la description signée, jamais de self-update.json.
|
||||
var settings = new SelfUpdateSettings { AutoUpdate = false };
|
||||
var json = JsonSerializer.Serialize(settings, JsonDefaults.Options);
|
||||
Assert.DoesNotContain("priority", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(JsonSerializer.Deserialize<SelfUpdateSettings>(json, JsonDefaults.Options)!.AutoUpdate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Maintenance;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Tests.Admin;
|
||||
|
||||
namespace GameLauncher.Tests.Signing;
|
||||
|
||||
/// <summary>Mode maintenance lu par le launcher des joueurs : vérification, retour en arrière, hors ligne, changements signalés.</summary>
|
||||
public class MaintenanceServiceTests
|
||||
{
|
||||
private static readonly Uri KeyringUrl = new("https://launcher.example/catalog/keyring.json");
|
||||
private static readonly Uri MaintenanceUrl = new("https://launcher.example/catalog/maintenance.json");
|
||||
|
||||
/// <summary>Serveur simulé : trousseau et maintenance.json (absent : 404 ; Offline : réseau coupé).</summary>
|
||||
private sealed class FakeServer
|
||||
{
|
||||
public byte[]? Keyring { get; set; }
|
||||
public byte[]? Maintenance { get; set; }
|
||||
public bool Offline { get; set; }
|
||||
public int MaintenanceRequests { get; private set; }
|
||||
|
||||
public Task<byte[]> Download(Uri uri, long max, CancellationToken ct)
|
||||
{
|
||||
if (Offline) throw new HttpRequestException("hors ligne");
|
||||
if (uri == KeyringUrl) return Keyring is { } k ? Task.FromResult(k) : throw new HttpRequestException("404", null, HttpStatusCode.NotFound);
|
||||
MaintenanceRequests++;
|
||||
if (Maintenance is not { } bytes) throw new HttpRequestException("404", null, HttpStatusCode.NotFound);
|
||||
if (bytes.Length > max) throw new InvalidDataException("réponse trop volumineuse");
|
||||
return Task.FromResult(bytes);
|
||||
}
|
||||
|
||||
public void Serve(MaintenanceDocument document) => Maintenance = Encoding.UTF8.GetBytes(MaintenanceRules.Serialize(document));
|
||||
}
|
||||
|
||||
private static MaintenanceDocument Signed(ECDsa key, long revision, bool enabled, string? message = null, DateTimeOffset? signedAt = null)
|
||||
{
|
||||
var document = new MaintenanceDocument
|
||||
{
|
||||
Revision = revision,
|
||||
Enabled = enabled,
|
||||
Message = enabled ? message : null,
|
||||
Since = enabled ? DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeSeconds()) : null
|
||||
};
|
||||
return document with
|
||||
{
|
||||
Signature = SigningKit.Sign(key, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static (MaintenanceService Service, FakeServer Server, List<MaintenanceDocument?> Changes) Create(
|
||||
string folder, ECDsa root, ECDsa admin, ContentSigningMode mode, params string[] scopes)
|
||||
{
|
||||
var server = new FakeServer();
|
||||
server.Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
|
||||
SigningKit.Entry(admin, "Nicolas", scopes.Length == 0 ? new[] { SigningScopes.Maintenance } : scopes))));
|
||||
var trust = mode == ContentSigningMode.Off
|
||||
? null
|
||||
: new ContentTrustService(server.Download, folder, KeyringUrl, new[] { SigningKit.Root(root) }, mode);
|
||||
var service = new MaintenanceService(server.Download, MaintenanceUrl, trust);
|
||||
var changes = new List<MaintenanceDocument?>();
|
||||
service.Changed += changes.Add;
|
||||
return (service, server, changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signed_maintenance_is_applied_then_ended_and_each_change_is_reported_once()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
|
||||
// Rien de publié (404) : pas de maintenance, rien à signaler.
|
||||
Assert.False(await service.RefreshAsync());
|
||||
Assert.Empty(changes);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
server.Serve(Signed(admin, 1, true, "Retour vers 18 h.", now.AddMinutes(-3)));
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.True(service.IsActive);
|
||||
Assert.Equal("Retour vers 18 h.", Assert.Single(changes)!.Message);
|
||||
|
||||
// Même fichier : rien de nouveau.
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.Single(changes);
|
||||
|
||||
// Nouveau message : signalé.
|
||||
server.Serve(Signed(admin, 2, true, "Retour vers 19 h.", now.AddMinutes(-2)));
|
||||
await service.RefreshAsync();
|
||||
Assert.Equal(2, changes.Count);
|
||||
Assert.Equal("Retour vers 19 h.", changes[^1]!.Message);
|
||||
|
||||
// Coupure réseau pendant la maintenance : dernier état vérifié conservé.
|
||||
server.Offline = true;
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.Equal(2, changes.Count);
|
||||
server.Offline = false;
|
||||
|
||||
// Fin de la maintenance.
|
||||
server.Serve(Signed(admin, 3, false, signedAt: now.AddMinutes(-1)));
|
||||
Assert.False(await service.RefreshAsync());
|
||||
Assert.False(service.IsActive);
|
||||
Assert.Equal(3, changes.Count);
|
||||
Assert.False(changes[^1]!.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task In_require_mode_forged_unsigned_or_replayed_maintenance_is_ignored()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
using var attacker = SigningKit.NewKey();
|
||||
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Non signé, signé par une clé absente du trousseau, ou contenu modifié après signature : ignorés.
|
||||
var unsigned = Signed(admin, 1, true) with { Signature = null };
|
||||
var forged = Signed(attacker, 1, true);
|
||||
var tampered = Signed(admin, 1, true, "Message d'origine") with { Message = "Message modifié" };
|
||||
foreach (var document in new[] { unsigned, forged, tampered })
|
||||
{
|
||||
server.Serve(document);
|
||||
Assert.False(await service.RefreshAsync());
|
||||
}
|
||||
Assert.Empty(changes);
|
||||
|
||||
// Légitime : maintenance, puis fin (signée plus tard).
|
||||
var old = Signed(admin, 1, true, signedAt: now.AddMinutes(-10));
|
||||
server.Serve(old);
|
||||
Assert.True(await service.RefreshAsync());
|
||||
server.Serve(Signed(admin, 2, false, signedAt: now.AddMinutes(-5)));
|
||||
Assert.False(await service.RefreshAsync());
|
||||
|
||||
// Un serveur compromis ressert l'ancienne maintenance légitime : refusée (révision et date de signature).
|
||||
server.Serve(old);
|
||||
Assert.False(await service.RefreshAsync());
|
||||
|
||||
// Même après un redémarrage du launcher (date de la dernière signature acceptée gardée dans l'état des signatures).
|
||||
var (restarted, restartedServer, _) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
restartedServer.Serve(old);
|
||||
Assert.False(await restarted.RefreshAsync());
|
||||
|
||||
// Hors limites, même bien signé : ignoré.
|
||||
var linked = new MaintenanceDocument { Revision = 3, Enabled = true, Message = "Infos : discord.gg/x", Since = old.Since };
|
||||
server.Serve(linked with
|
||||
{
|
||||
Signature = SigningKit.Sign(admin, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(linked))
|
||||
});
|
||||
Assert.False(await service.RefreshAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_key_without_the_maintenance_scope_cannot_start_a_maintenance()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require, SigningScopes.Catalog, SigningScopes.Releases);
|
||||
server.Serve(Signed(admin, 1, true));
|
||||
Assert.False(await service.RefreshAsync());
|
||||
Assert.Empty(changes);
|
||||
}
|
||||
|
||||
/// <summary>Horloge avancée à la main (trousseau).</summary>
|
||||
private sealed class ManualClock : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_maintenance_published_after_the_keyring_gained_the_scope_is_applied_without_restarting()
|
||||
{
|
||||
// Cas signalé en 3.3.0 : launcher ouvert, trousseau republié avec l'autorisation « maintenance » (étape 3 decies),
|
||||
// puis maintenance activée. Le launcher gardait l'ancien trousseau (clé connue, sans l'autorisation) jusqu'au
|
||||
// redémarrage : chaque relecture refusait la maintenance.
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
var server = new FakeServer
|
||||
{
|
||||
Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
|
||||
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog))))
|
||||
};
|
||||
var clock = new ManualClock();
|
||||
var trust = new ContentTrustService(server.Download, folder.Path, KeyringUrl, new[] { SigningKit.Root(root) },
|
||||
ContentSigningMode.Require, time: clock);
|
||||
var service = new MaintenanceService(server.Download, MaintenanceUrl, trust, clock);
|
||||
var changes = new List<MaintenanceDocument?>();
|
||||
service.Changed += changes.Add;
|
||||
|
||||
// Démarrage : rien de publié, trousseau n° 1 en mémoire.
|
||||
Assert.NotNull(await trust.GetKeyringAsync());
|
||||
Assert.False(await service.RefreshAsync());
|
||||
|
||||
// Trousseau n° 2 (autorisation « maintenance » ajoutée), puis maintenance activée quelques minutes plus tard.
|
||||
server.Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 2,
|
||||
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog, SigningScopes.Maintenance))));
|
||||
clock.Now += TimeSpan.FromMinutes(3);
|
||||
server.Serve(Signed(admin, 1, true, "Retour vers 18 h."));
|
||||
|
||||
// Relecture périodique suivante : trousseau retéléchargé, maintenance appliquée.
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.True(service.IsActive);
|
||||
Assert.Equal("Retour vers 18 h.", Assert.Single(changes)!.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Offline_at_startup_or_without_address_there_is_no_maintenance_and_warn_mode_applies_it()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
|
||||
// Hors ligne au démarrage : jamais de maintenance (le jeu installé reste jouable).
|
||||
var (offline, offlineServer, _) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
offlineServer.Offline = true;
|
||||
Assert.False(await offline.RefreshAsync());
|
||||
Assert.Null(offline.Current);
|
||||
|
||||
// Sans adresse (ni trousseau ni catalogue) : rien n'est lu.
|
||||
Assert.Null(MaintenanceService.PublishedUrl(null, null));
|
||||
Assert.Equal(MaintenanceUrl, MaintenanceService.PublishedUrl(KeyringUrl.AbsoluteUri, null));
|
||||
var none = new MaintenanceService((_, _, _) => throw new InvalidOperationException("aucune requête attendue"), null, null);
|
||||
Assert.False(await none.RefreshAsync());
|
||||
|
||||
// « warn » : document non signé appliqué (signalé dans le journal) ; « off » : appliqué sans vérification.
|
||||
foreach (var mode in new[] { ContentSigningMode.Warn, ContentSigningMode.Off })
|
||||
{
|
||||
using var modeFolder = new TempFolder();
|
||||
var (service, server, changes) = Create(modeFolder.Path, root, admin, mode);
|
||||
server.Serve(Signed(admin, 1, true) with { Signature = null });
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.Single(changes);
|
||||
}
|
||||
|
||||
// Fichier trop volumineux : ignoré.
|
||||
var (big, bigServer, bigChanges) = Create(folder.Path, root, admin, ContentSigningMode.Off);
|
||||
bigServer.Maintenance = new byte[MaintenanceRules.MaxBytes + 1];
|
||||
Assert.False(await big.RefreshAsync());
|
||||
Assert.Empty(bigChanges);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ namespace LauncherPublisher;
|
||||
/// --config src\GameLauncher\launcher.json --output "D:\Publish\launcher"
|
||||
/// [--version 1.2.0] [--notes notes-launcher.txt]
|
||||
/// [--public-key src\GameLauncher\Security\update-signing.pub]
|
||||
/// [--priority | --clear-priority] [--yes]
|
||||
/// LauncherPublisher verify --manifest "D:\Publish\launcher\launcher-update.json"
|
||||
/// --public-key src\GameLauncher\Security\update-signing.pub [--exe fichier.exe]
|
||||
///
|
||||
@@ -49,6 +50,11 @@ public static class Program
|
||||
default: return Usage();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
Warn(ex.Message);
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Error($"Erreur : {ex.Message}");
|
||||
@@ -136,6 +142,22 @@ public static class Program
|
||||
@"Augmentez <Version> dans src\GameLauncher\GameLauncher.csproj, recompilez le launcher, puis relancez. (--force pour passer outre)");
|
||||
}
|
||||
|
||||
// Version PRIORITAIRE (correctif critique) : installée au démarrage même chez les joueurs qui ont désactivé la
|
||||
// mise à jour automatique (leur réglage n'est pas modifié). Confirmation demandée : rien n'est encore écrit.
|
||||
var markPriority = o.Has("--priority");
|
||||
var clearPriority = o.Has("--clear-priority");
|
||||
if (markPriority && clearPriority)
|
||||
throw new ArgumentException("--priority et --clear-priority sont incompatibles.");
|
||||
if (markPriority && !o.Has("--yes"))
|
||||
{
|
||||
Warn($"Version PRIORITAIRE : le launcher {display} sera installé au démarrage MÊME chez les joueurs qui ont désactivé " +
|
||||
"la mise à jour automatique (leur réglage n'est pas modifié). À réserver aux correctifs critiques (faille de sécurité…).");
|
||||
Console.Write("Publier cette version comme prioritaire ? (o/N) ");
|
||||
var answer = Console.ReadLine()?.Trim().ToLowerInvariant();
|
||||
if (answer is not ("o" or "oui"))
|
||||
throw new OperationCanceledException("Abandon : rien n'a été publié.");
|
||||
}
|
||||
|
||||
using var key = LoadPrivateKey(keyPath);
|
||||
var keyId = UpdateSignature.ComputeKeyId(key.ExportSubjectPublicKeyInfo());
|
||||
|
||||
@@ -172,10 +194,20 @@ public static class Program
|
||||
ChangelogParser.Parse(await File.ReadAllLinesAsync(notesFile), entry);
|
||||
}
|
||||
|
||||
// Historique : notes des versions déjà publiées (launcher-update.json précédent, signé par cette clé)
|
||||
var history = ReadHistory(previousJson, key, keyId, display);
|
||||
// launcher-update.json précédent, signé par cette clé : historique des notes et version prioritaire repris
|
||||
var previousRelease = ReadPreviousRelease(previousJson, key, keyId);
|
||||
var history = ReadHistory(previousRelease, display);
|
||||
var changelog = ChangelogLimits.Normalize(new[] { entry }.Concat(history));
|
||||
|
||||
// Version prioritaire : celle-ci (--priority), aucune (--clear-priority), sinon celle déjà publiée
|
||||
var priorityVersion = LauncherRelease.ResolvePriorityVersion(version, markPriority, clearPriority, previousRelease);
|
||||
if (previousRelease is null && previousJson is not null && !markPriority && !clearPriority &&
|
||||
TryReadUnverified(previousJson)?.PriorityVersion is { Length: > 0 } unverifiedPriority)
|
||||
Warn($"Version prioritaire {unverifiedPriority} du launcher-update.json précédent NON reprise (signature non vérifiée) : " +
|
||||
"relancez avec --priority si cette version doit rester imposée.");
|
||||
if (clearPriority && previousRelease?.PriorityVersion is { Length: > 0 } removedPriority)
|
||||
Warn($"Version prioritaire {removedPriority} retirée : seuls les joueurs ayant activé la mise à jour automatique recevront cette version.");
|
||||
|
||||
var release = new LauncherRelease
|
||||
{
|
||||
Product = product,
|
||||
@@ -187,7 +219,8 @@ public static class Program
|
||||
// Format historique, lu par les launchers antérieurs à 1.1.3
|
||||
Title = entry.Title,
|
||||
Notes = entry.Changes.ToList(),
|
||||
Changelog = changelog
|
||||
Changelog = changelog,
|
||||
PriorityVersion = priorityVersion
|
||||
};
|
||||
release.Validate();
|
||||
|
||||
@@ -219,6 +252,7 @@ public static class Program
|
||||
? " Notes : aucune (--notes non fourni ou fichier vide)"
|
||||
: $" Notes : « {current.Title ?? "sans titre"} », {current.Sections.Count} section(s), " +
|
||||
$"{current.Sections.Sum(x => x.Items.Count)} élément(s) ; historique : {release.Changelog.Count - 1} version(s)");
|
||||
Console.WriteLine($" Priorité : {DescribePriority(release)}");
|
||||
Console.WriteLine($" Produit : {product}");
|
||||
Console.WriteLine($" Clé : {keyId}");
|
||||
Console.WriteLine($" SHA-256 : {sha256}");
|
||||
@@ -245,6 +279,7 @@ public static class Program
|
||||
Console.WriteLine($" Version : {release.Version} ({release.PublishedAt:yyyy-MM-dd HH:mm})");
|
||||
Console.WriteLine($" Fichier : {release.File} ({Format.Bytes(release.Size)})");
|
||||
Console.WriteLine($" SHA-256 : {release.Sha256}");
|
||||
Console.WriteLine($" Priorité: {DescribePriority(release)}");
|
||||
|
||||
var exe = o.Get("--exe");
|
||||
if (exe is null) return true;
|
||||
@@ -295,25 +330,30 @@ public static class Program
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notes des versions déjà publiées, reprises du launcher-update.json précédent — uniquement
|
||||
/// si sa signature est valide pour la clé utilisée (un fichier modifié à la main est ignoré).
|
||||
/// launcher-update.json précédent — uniquement si sa signature est valide pour la clé utilisée (un fichier
|
||||
/// modifié à la main est ignoré) : son historique des notes et sa version prioritaire sont repris.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<ChangelogEntry> ReadHistory(string? previousJson, ECDsa key, string keyId, string version)
|
||||
private static LauncherRelease? ReadPreviousRelease(string? previousJson, ECDsa key, string keyId)
|
||||
{
|
||||
if (previousJson is null) return Array.Empty<ChangelogEntry>();
|
||||
LauncherRelease previous;
|
||||
if (previousJson is null) return null;
|
||||
try
|
||||
{
|
||||
previous = UpdateSignature.ReadRelease(previousJson, new[]
|
||||
return UpdateSignature.ReadRelease(previousJson, new[]
|
||||
{
|
||||
new UpdateSignature.TrustedKey(keyId, key.ExportSubjectPublicKeyInfo())
|
||||
});
|
||||
}
|
||||
catch (UpdateSecurityException ex)
|
||||
{
|
||||
Warn($"Historique des notes ignoré ({ex.Message}).");
|
||||
return Array.Empty<ChangelogEntry>();
|
||||
Warn($"launcher-update.json précédent ignoré ({ex.Message}) : historique des notes et version prioritaire non repris.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Notes des versions déjà publiées (publication précédente vérifiée), sans la version en cours.</summary>
|
||||
private static IReadOnlyList<ChangelogEntry> ReadHistory(LauncherRelease? previous, string version)
|
||||
{
|
||||
if (previous is null) return Array.Empty<ChangelogEntry>();
|
||||
|
||||
// Version publiée avant le format enrichi : son titre et sa liste deviennent une entrée
|
||||
var entries = previous.Changelog.Count > 0
|
||||
@@ -322,6 +362,16 @@ public static class Program
|
||||
return entries.Where(e => !string.Equals(e.Version, version, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Ligne « Priorité » affichée après publish et verify.</summary>
|
||||
private static string DescribePriority(LauncherRelease release)
|
||||
{
|
||||
if (!release.TryGetPriorityVersion(out var priority)) return "normale (installée selon le réglage de chaque joueur)";
|
||||
var display = VersionUtil.Display(priority);
|
||||
return release.IsPriorityRelease
|
||||
? "PRIORITAIRE — installée au démarrage même si la mise à jour automatique est désactivée (réglage du joueur conservé)"
|
||||
: $"normale, version prioritaire {display} reprise — les launchers antérieurs à {display} se mettent à jour quel que soit leur réglage";
|
||||
}
|
||||
|
||||
private static LauncherRelease? TryReadUnverified(string envelopeJson)
|
||||
{
|
||||
try
|
||||
@@ -509,7 +559,14 @@ public static class Program
|
||||
(--config <launcher.json> | --product <launcherId>)
|
||||
[--version x.y.z] [--notes <notes.txt>] [--title <texte>]
|
||||
[--public-key <update-signing.pub>] [--force]
|
||||
[--priority | --clear-priority] [--yes]
|
||||
Signe la nouvelle version et prépare le dossier à envoyer sur le serveur.
|
||||
--priority : version PRIORITAIRE (correctif critique), installée au démarrage même
|
||||
chez les joueurs qui ont désactivé la mise à jour automatique (leur
|
||||
réglage n'est pas modifié). Confirmation demandée, sauf --yes.
|
||||
Reprise par les publications suivantes : un launcher plus ancien que
|
||||
cette version se met à jour quoi qu'il arrive.
|
||||
--clear-priority : ne reprend pas la version prioritaire déjà publiée.
|
||||
|
||||
verify --manifest <launcher-update.json | https://…> --public-key <update-signing.pub> [--exe <fichier>]
|
||||
Vérifie la signature (et l'exécutable).
|
||||
|
||||
@@ -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:
|
||||
@@ -709,10 +716,46 @@ internal sealed class SampleWorld
|
||||
Digest = SampleData.Digest("catalogue"),
|
||||
Signature = Signature(-3),
|
||||
SignerName = "Camille — PC-STUDIO"
|
||||
},
|
||||
// Formulaires des demandes de démonstration (révision 3, comme la page « Formulaires des demandes »).
|
||||
FeedbackForms = new FeedbackFormsSignatureState
|
||||
{
|
||||
Revision = 3,
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,9 +32,12 @@ 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
|
||||
d'installation, mise à jour du launcher (téléchargement, version vérifiée, version prioritaire
|
||||
`launcher-update-priority`, erreur), boîtes de dialogue
|
||||
(information, succès, question, avertissement, erreur, action irréversible avec option), menu de la zone
|
||||
de notification.
|
||||
* **Fenêtre « Aide »** (`feedback-*`, bouton de la barre de titre) : choix du type de demande, avis noté,
|
||||
@@ -45,13 +48,15 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
|
||||
démonstration (ni fichier choisi, ni presse-papiers).
|
||||
* **Menu d'administration** (`admin-*`) : connexion (normale, refusée, verrouillée), « J'ai une
|
||||
invitation » (formulaire, empreinte), puis **chaque page déclarée par les modules** (`admin-page-<id>`,
|
||||
lues dans `App.CreateModules` : une page ajoutée plus tard est capturée sans modifier l'outil), une
|
||||
version prête à publier, la fiche d'une demande de joueur (`admin-page-feedback-requests-detail` :
|
||||
lues dans `App.CreateModules` : une page ajoutée plus tard est capturée sans modifier l'outil ; la page
|
||||
« Signature », `admin-page-signing`, montre les versions en ligne, le catalogue et les formulaires des
|
||||
demandes), une version prête à publier, la fiche d'une demande de joueur (`admin-page-feedback-requests-detail` :
|
||||
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`).
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
@@ -120,6 +137,7 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
yield return new SceneInfo("install-dialog" + suffix, "Installation : choix du dossier");
|
||||
yield return new SceneInfo("launcher-update" + suffix, "Mise à jour du launcher · téléchargement");
|
||||
yield return new SceneInfo("launcher-update-verified" + suffix, "Mise à jour du launcher · version vérifiée");
|
||||
yield return new SceneInfo("launcher-update-priority" + suffix, "Mise à jour du launcher · version prioritaire");
|
||||
yield return new SceneInfo("launcher-update-error" + suffix, "Mise à jour du launcher · erreur");
|
||||
foreach (var (id, title, _) in Dialogs) yield return new SceneInfo(id + suffix, title);
|
||||
yield return new SceneInfo("tray-menu" + suffix, "Menu de la zone de notification");
|
||||
@@ -132,7 +150,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 +171,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();
|
||||
@@ -272,6 +291,21 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
return Task.FromResult<Window>(new LauncherUpdateWindow { DataContext = viewModel });
|
||||
}, CloseUpdateWindowAsync);
|
||||
|
||||
// Version prioritaire (correctif critique) : pastille « Prioritaire » et texte dédié
|
||||
var priorityRelease = SampleData.CreateLauncherRelease(_fixture.CurrentVersion);
|
||||
priorityRelease.PriorityVersion = priorityRelease.Version;
|
||||
var priorityCheck = new LauncherUpdateCheck(_fixture.CurrentVersion, priorityRelease,
|
||||
new Uri("https://jeux.exemple.test/launcher/GameLauncher.exe"));
|
||||
await capture.ShootAsync("launcher-update-priority" + suffix, "Mise à jour du launcher · version prioritaire", () =>
|
||||
{
|
||||
_fixture.ApplyTheme(theme);
|
||||
var viewModel = new LauncherUpdateViewModel(priorityCheck, automatic: true);
|
||||
viewModel.Progress.Report(new OperationProgress("Téléchargement de la nouvelle version",
|
||||
$"{Format.Bytes(25_800_000)} / {Format.Bytes(priorityRelease.Size)} • {Format.Speed(6_300_000)} • " +
|
||||
$"{Format.Duration(TimeSpan.FromSeconds(2))} restantes", 67.5));
|
||||
return Task.FromResult<Window>(new LauncherUpdateWindow { DataContext = viewModel });
|
||||
}, CloseUpdateWindowAsync);
|
||||
|
||||
await capture.ShootAsync("launcher-update-error" + suffix, "Mise à jour du launcher · erreur", () =>
|
||||
{
|
||||
_fixture.ApplyTheme(theme);
|
||||
|
||||
+40
-110
@@ -1,140 +1,70 @@
|
||||
# Launcher 3.2.0
|
||||
# Launcher 3.4.0
|
||||
LAUNCHER - NOTES DE MISE À JOUR
|
||||
Formulaires des demandes modifiables - Mise à jour du 10 novembre 2026
|
||||
==================================================================
|
||||
Mises à jour prioritaires du launcher - Mise à jour du 10 novembre 2026
|
||||
=========================================================================
|
||||
|
||||
Les formulaires du bouton « Aide » se gèrent désormais depuis le menu d'administration : de 1 à 8 types de demande (ajout, suppression, ordre d'affichage, icône, captures d'écran autorisées ou non) et leurs champs, avec un aperçu identique à la fenêtre des joueurs.
|
||||
Une version du launcher peut désormais être publiée comme prioritaire, pour un correctif critique (faille de sécurité…) : elle s'installe au démarrage même si vous avez désactivé la mise à jour automatique du launcher. Votre réglage n'est pas modifié : les versions normales suivantes ne s'installeront toujours pas toutes seules.
|
||||
|
||||
Les formulaires sont communs à tous les jeux, signés et vérifiés par le launcher, et ne peuvent contenir aucun lien. Le serveur est à redéployer, avec deux blocs nginx à ajouter ; le trousseau est à re-signer une fois cette version diffusée.
|
||||
Aucune action sur le serveur. Le launcher se met à jour automatiquement.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
EN BREF
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : page « Formulaires des demandes » dans le menu :
|
||||
types de demande, champs, aperçu, contrôle en direct, publication
|
||||
signée.
|
||||
- NOUVEAU : 1 à 8 types de demande : ajout, suppression, ordre
|
||||
d'affichage, type masqué.
|
||||
- NOUVEAU : icône d'un type : l'une des quatre d'origine, ou un
|
||||
fichier .ico importé (image 64 × 64 obligatoire).
|
||||
- NOUVEAU : captures d'écran autorisées ou non par type de demande.
|
||||
- NOUVEAU : champs texte court, cases à cocher, case oui/non, notes
|
||||
de 3, 5 ou 10 étoiles.
|
||||
- AMÉLIORÉ : fenêtre « Aide » : formulaires publiés, saisie gardée
|
||||
si les formulaires changent pendant la saisie.
|
||||
- AMÉLIORÉ : chaque demande garde le formulaire de son envoi (nom,
|
||||
icône, libellés).
|
||||
- SÉCURITÉ : formulaires signés et revérifiés par le launcher ;
|
||||
aucun lien accepté dans les textes.
|
||||
- SERVEUR : à redéployer, avec deux blocs nginx ; trousseau à
|
||||
re-signer après la diffusion de la 3.2.0.
|
||||
- QUALITÉ : 13 nouveaux tests automatisés (355 au total).
|
||||
- NOUVEAU : version prioritaire, installée au démarrage même avec
|
||||
la mise à jour automatique du launcher désactivée.
|
||||
- NOUVEAU : pastille « Prioritaire » dans la fenêtre de mise à
|
||||
jour, avertissement dans le bandeau et les paramètres tant
|
||||
qu'elle n'est pas installée.
|
||||
- SÉCURITÉ : l'indication « prioritaire » est signée, comme le
|
||||
reste de la mise à jour.
|
||||
- QUALITÉ : nouveaux tests automatisés.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
PAGE « FORMULAIRES DES DEMANDES » (ADMINISTRATION)
|
||||
VERSION PRIORITAIRE
|
||||
------------------------------------------------------------------
|
||||
- Permission « Formulaires des demandes » (feedback.forms).
|
||||
- Types de demande : ajouter (8 au plus), monter, descendre,
|
||||
supprimer (avec confirmation ; les demandes reçues restent
|
||||
consultables), masquer.
|
||||
- Réglages d'un type : nom, description, exemple de titre,
|
||||
informations techniques cochées par défaut, captures d'écran
|
||||
autorisées (3 au plus), icône.
|
||||
- Champs : ajouter, monter, descendre, retirer ; libellé, aide,
|
||||
obligatoire, aperçu dans la liste, longueur, nombre d'étoiles,
|
||||
choix (2 à 20).
|
||||
- Types de champs : zone de texte, texte court, liste déroulante,
|
||||
cases à cocher, case oui/non, étoiles (3, 5 ou 10).
|
||||
- Aperçu identique à la fenêtre « Aide » ; contrôle en direct, la
|
||||
publication reste bloquée tant qu'il reste une erreur.
|
||||
- Avertissement si un libellé semble demander une donnée
|
||||
personnelle (e-mail, mot de passe, téléphone…).
|
||||
- « Publier », « Annuler les modifications », « Revenir à
|
||||
l'origine », « Recharger » ; une publication d'un autre
|
||||
administrateur n'est jamais écrasée.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
ICÔNES IMPORTÉES
|
||||
------------------------------------------------------------------
|
||||
- Fichier .ico seulement (1 Mo au plus), contenant une image de
|
||||
64 × 64 pixels ; les autres tailles du fichier sont ignorées.
|
||||
- PNG intégré ou bitmap 24/32 bits ; 256 couleurs et moins refusés.
|
||||
- Image réécrite en PNG neuf, sans métadonnées, intégrée aux
|
||||
formulaires et couverte par leur signature.
|
||||
- Un fichier refusé affiche un message qui explique pourquoi.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
FENÊTRE « AIDE » ET DEMANDES DES JOUEURS
|
||||
------------------------------------------------------------------
|
||||
- Formulaires publiés lus à l'ouverture de la fenêtre, puis
|
||||
vérifiés (règles, signature, révision).
|
||||
- Types dans l'ordre choisi, avec leur icône ; types masqués
|
||||
absents ; section « Captures d'écran » masquée si le type ne les
|
||||
autorise pas.
|
||||
- Formulaires republiés pendant la saisie : formulaire reconstruit,
|
||||
saisie des champs inchangés gardée.
|
||||
- Hors ligne ou formulaires refusés : formulaires d'origine.
|
||||
- Menu : chaque demande s'affiche avec le formulaire de son envoi ;
|
||||
filtre par type publié, « (masqué) » ou « (supprimé) ».
|
||||
- Journal d'audit : « Formulaires des demandes publiés », avec le
|
||||
détail des changements par type.
|
||||
- Votre réglage « Mettre à jour le launcher automatiquement »
|
||||
n'est jamais modifié : effet ponctuel, le temps d'installer le
|
||||
correctif.
|
||||
- « Plus tard » reste possible : nouvelle tentative au prochain
|
||||
démarrage du launcher.
|
||||
- Une erreur de téléchargement est retentée au démarrage suivant.
|
||||
- Si la nouvelle version ne démarre pas, l'ancienne est remise en
|
||||
place et le bouton « Vérifier les mises à jour » permet de
|
||||
réessayer.
|
||||
- Mise à jour automatique désactivée : le launcher vérifie
|
||||
désormais au démarrage si une version prioritaire existe.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
SÉCURITÉ
|
||||
------------------------------------------------------------------
|
||||
- Formulaires signés par une clé autorisée « formulaires » dans le
|
||||
trousseau ; vérifiés par chaque launcher, comme le catalogue.
|
||||
- Aucun lien dans les noms, descriptions, libellés, aides et choix :
|
||||
refusé par le menu, le serveur et le launcher.
|
||||
- Ancienne révision refusée par le launcher.
|
||||
- Publication par requête signée, avec la permission
|
||||
feedback.forms ; révision attendue (rien n'est écrasé).
|
||||
- Taille limitée : 256 Ko de formulaires, icônes comprises.
|
||||
- Le serveur refuse une demande avec captures pour un type qui ne
|
||||
les autorise pas, et une demande pour un type masqué ou supprimé.
|
||||
- Une autorisation inconnue du trousseau est désormais acceptée sans
|
||||
effet.
|
||||
- L'indication « prioritaire » fait partie du contenu signé : un
|
||||
serveur compromis ne peut ni imposer une mise à jour, ni
|
||||
retirer la priorité d'un correctif.
|
||||
- Seule une version signée et strictement supérieure peut être
|
||||
installée, comme avant.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
SERVEUR
|
||||
------------------------------------------------------------------
|
||||
- À redéployer : server/deploy/LISEZMOI.md, étape 8, puis
|
||||
étape 3 nonies.
|
||||
- nginx : ajouter « location = /catalog/feedback-forms.json »
|
||||
(nginx-catalog.conf) et « location = /api/v1/feedback/forms »
|
||||
(nginx-api.conf, 300 Ko).
|
||||
- Publier ensuite le launcher 3.2.0, puis donner la permission
|
||||
feedback.forms et re-signer le trousseau.
|
||||
- Les launchers 3.0 / 3.1 envoient toujours leurs demandes avec les
|
||||
formulaires d'origine, acceptées sauf type masqué ou supprimé.
|
||||
- Aucun changement.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
DÉVELOPPEMENT
|
||||
------------------------------------------------------------------
|
||||
- Contrats : FeedbackFormsDocument (document signé et règles),
|
||||
FeedbackIcons (lecture des .ico, sans bibliothèque).
|
||||
- Signature : type de contenu feedback-forms, autorisation « forms »
|
||||
du trousseau.
|
||||
- Serveur : FeedbackFormsStore (référence privée, historique de 20
|
||||
publications, copie publique) ; route v1/feedback/forms.
|
||||
- Launcher : FeedbackFormsService (lecture et vérification),
|
||||
Themes/FeedbackForms.xaml (modèles partagés avec l'aperçu).
|
||||
- Menu : FeedbackFormsPanelViewModel, FeedbackFormEditors.
|
||||
- Outil ui-snapshot : scènes admin-page-feedback-forms-edit,
|
||||
admin-page-feedback-forms-new-type, feedback-custom-kinds et
|
||||
feedback-custom.
|
||||
- Tests : FeedbackFormsTests.cs, FeedbackFormsModuleTests.cs,
|
||||
FeedbackFormsPanelTests.cs (nouveaux), ContentSigningTests.cs,
|
||||
FeedbackPanelTests.cs.
|
||||
- Documentation : README.md, docs/ADMIN-ARCHITECTURE.md
|
||||
(§ 11 sexies) et server/deploy/LISEZMOI.md mis à jour.
|
||||
- publish-launcher-update.bat --priority (version prioritaire,
|
||||
confirmation demandée) et --clear-priority (ne reprend pas la
|
||||
priorité déjà publiée).
|
||||
- LauncherPublisher : options --priority, --clear-priority, --yes ;
|
||||
ligne « Priorité » dans publish et verify.
|
||||
- Core : LauncherRelease.PriorityVersion (reprise d'une
|
||||
publication à l'autre), LauncherUpdateCheck.IsPriority.
|
||||
- UiSnapshot : scène launcher-update-priority.
|
||||
- Tests : LauncherPriorityTests.
|
||||
|
||||
|
||||
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.
|
||||
@@ -7,8 +7,41 @@ REM 3. (optionnel) l'envoie sur le VPS avec WinSCP
|
||||
REM
|
||||
REM Avant de lancer : augmentez <Version> dans src\GameLauncher\GameLauncher.csproj
|
||||
REM et ecrivez les nouveautes dans tools\notes-launcher.txt.
|
||||
REM
|
||||
REM Parametres (facultatifs, depuis une invite de commandes) :
|
||||
REM publish-launcher-update.bat version normale (double-clic)
|
||||
REM publish-launcher-update.bat --priority version PRIORITAIRE (correctif critique) :
|
||||
REM installee au demarrage MEME chez les joueurs qui ont desactive la mise a jour
|
||||
REM automatique du launcher. Leur reglage (self-update.json) n'est pas modifie.
|
||||
REM Reprise par les publications suivantes : un launcher plus ancien que cette
|
||||
REM version se met a jour quoi qu'il arrive. Confirmation demandee.
|
||||
REM publish-launcher-update.bat --clear-priority ne reprend pas la version prioritaire
|
||||
REM deja publiee (priorite publiee par erreur).
|
||||
REM ---------------------------------------------------------------------------
|
||||
setlocal
|
||||
set PRIORITY=
|
||||
|
||||
:args
|
||||
if "%~1"=="" goto :argsdone
|
||||
if /i "%~1"=="--priority" goto :opt_priority
|
||||
if /i "%~1"=="--clear-priority" goto :opt_clear
|
||||
if /i "%~1"=="--help" goto :usage
|
||||
if "%~1"=="/?" goto :usage
|
||||
echo Parametre inconnu : %~1
|
||||
goto :usage
|
||||
:opt_priority
|
||||
if defined PRIORITY goto :conflict
|
||||
set PRIORITY=--priority
|
||||
REM shift /1 : %0 (donc %~dp0) reste inchange
|
||||
shift /1
|
||||
goto :args
|
||||
:opt_clear
|
||||
if defined PRIORITY goto :conflict
|
||||
set PRIORITY=--clear-priority
|
||||
shift /1
|
||||
goto :args
|
||||
:argsdone
|
||||
|
||||
set ROOT=%~dp0..
|
||||
set KEY=%USERPROFILE%\.launcher-keys\launcher-signing.key
|
||||
set OUTPUT=C:\Users\solac\Documents\Game Laucher\GameLauncher_V2\Publish\Updates
|
||||
@@ -16,6 +49,18 @@ REM Script WinSCP (laisser vide pour envoyer les fichiers a la main) :
|
||||
set WINSCP_SCRIPT=C:\Users\solac\Documents\Game Laucher\GameLauncher_V2\Publish\publier-launcher-update.txt
|
||||
set WINSCP="C:\Program Files (x86)\WinSCP\WinSCP.com"
|
||||
|
||||
if /i "%PRIORITY%"=="--priority" (
|
||||
echo ***************************************************************************
|
||||
echo Version PRIORITAIRE : installee au demarrage meme chez les joueurs qui ont
|
||||
echo desactive la mise a jour automatique du launcher. Leur reglage ne change pas.
|
||||
echo ***************************************************************************
|
||||
echo.
|
||||
)
|
||||
if /i "%PRIORITY%"=="--clear-priority" (
|
||||
echo Version normale : la version prioritaire deja publiee n'est PAS reprise.
|
||||
echo.
|
||||
)
|
||||
|
||||
echo === 1/3 Compilation du launcher ===
|
||||
dotnet publish "%ROOT%\src\GameLauncher\GameLauncher.csproj" -c Release -p:PublishProfile=win-x64
|
||||
if errorlevel 1 goto :error
|
||||
@@ -28,6 +73,7 @@ dotnet run --project "%ROOT%\tools\LauncherPublisher\LauncherPublisher.csproj" -
|
||||
--config "%ROOT%\src\GameLauncher\launcher.json" ^
|
||||
--public-key "%ROOT%\src\GameLauncher\Security\update-signing.pub" ^
|
||||
--notes "%~dp0notes-launcher.txt" ^
|
||||
%PRIORITY% ^
|
||||
--output "%OUTPUT%"
|
||||
if errorlevel 1 goto :error
|
||||
|
||||
@@ -52,3 +98,15 @@ echo.
|
||||
echo La publication a echoue.
|
||||
pause
|
||||
exit /b 1
|
||||
|
||||
:conflict
|
||||
echo --priority et --clear-priority sont incompatibles.
|
||||
exit /b 1
|
||||
|
||||
:usage
|
||||
echo.
|
||||
echo Utilisation : publish-launcher-update.bat [--priority ^| --clear-priority]
|
||||
echo --priority version prioritaire (correctif critique) : installee meme si le
|
||||
echo joueur a desactive la mise a jour automatique (reglage conserve)
|
||||
echo --clear-priority ne reprend pas la version prioritaire deja publiee
|
||||
exit /b 1
|
||||
Reference in new issue
Block a user