Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edd2633455 | ||
|
|
0e26e6eb11 | ||
|
|
916b53446a | ||
|
|
0e371518b5 | ||
|
|
153e498345 | ||
|
|
1e9f49a733 | ||
|
|
5f6c667030 | ||
|
|
93dd844221 | ||
|
|
5ad8025946 | ||
|
|
080000e13c | ||
|
|
3422b92f81 | ||
|
|
683cc7a9dc | ||
|
|
7bc229de53 | ||
|
|
83e3d61634 | ||
|
|
b088c6caa7 | ||
|
|
658a1cce72 | ||
|
|
bac8ebeb55 | ||
|
|
76eaa48342 | ||
|
|
97df0ba721 | ||
|
|
f0f403ce66 | ||
|
|
977e2ae892 | ||
|
|
1913fcdb01 | ||
|
|
f25051c627 | ||
|
|
82ad4ec500 | ||
|
|
a4ef606ff7 | ||
|
|
fcb27d40b1 | ||
|
|
45140ba78a | ||
|
|
5457e66144 | ||
|
|
05c5e24327 | ||
|
|
d920702353 | ||
|
|
e96edfce4f |
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)
|
||||
@@ -512,7 +513,7 @@ Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4
|
||||
**Notes de version**. Phase 5 livrée : la page **Publier une version**. Phase 6 livrée : les pages
|
||||
**Mes appareils**, **Administrateurs** et **Journal d'audit**. Phase 7 livrée : la **signature du contenu**
|
||||
servi aux joueurs (page **Signature**, voir plus bas). Ajoutées ensuite : la page **Fichiers du serveur**, puis le
|
||||
bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode maintenance** (voir plus bas).
|
||||
|
||||
* **Ergonomie commune aux pages** : les boutons d'action (« Recharger », « Publier », « Enregistrer »,
|
||||
« Actualiser », « Exporter… ») restent affichés en bas de la fenêtre, quelle que soit la position dans la
|
||||
@@ -520,6 +521,18 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
molette fait défiler ce qui est sous le curseur : elle ne change jamais une liste déroulante fermée (même si
|
||||
elle vient d'être utilisée), et une liste ou un champ qui ne peut plus défiler laisse défiler la page.
|
||||
|
||||
* **Page « Maintenance »** (permission `maintenance.manage`) : **active ou désactive le mode maintenance de tous les
|
||||
launchers** (tous les jeux), avec une précision facultative pour les joueurs (6 lignes, 300 caractères, aucun lien ;
|
||||
aperçu en direct). Chez les joueurs : fond et accents **rouge / orange**, carte « Maintenance en cours — merci de
|
||||
revenir plus tard » au centre (avec la précision et l'heure de début), bouton principal « MAINTENANCE » ; installation,
|
||||
mises à jour (automatiques comprises), vérification, réparation, localisation, lancement (raccourcis compris),
|
||||
paramètres, bouton « Aide » et site officiel **bloqués** ; seules la **désinstallation** (menu « … » ou Windows) et le
|
||||
menu d'administration restent disponibles. Lu au démarrage avant la recherche des mises à jour, puis toutes les 2 min
|
||||
(1 min pendant une maintenance) ; le launcher de l'administrateur bascule aussitôt. **Signé** (autorisation « mode
|
||||
maintenance » du trousseau) et vérifié comme le catalogue ; hors ligne au démarrage : pas de maintenance. Les
|
||||
téléchargements commencés se terminent, les parties en cours ne sont pas fermées, les mises à jour du launcher
|
||||
lui-même continuent. Le tableau de bord rappelle une maintenance en cours. Mise en place : `server/deploy/LISEZMOI.md`,
|
||||
étape 3 decies.
|
||||
* **Bouton « Aide »** (joueurs, barre de titre, bulle avec un point d'interrogation) : **avis**, **demande
|
||||
d'évolution**, **bug** ou **autre**, pour le **jeu sélectionné** (affiché, non modifiable pendant la saisie). Champs
|
||||
prédéfinis selon le type (note en étoiles, catégorie, gravité, fréquence, étapes pour reproduire…), contrôlés avant
|
||||
@@ -529,6 +542,12 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
son contenu, réencodée en PNG/JPEG **sans métadonnées** (ni GPS, ni appareil), revérifiée par le serveur (PNG/JPEG
|
||||
seulement, 4 Mo). En cas d'échec (réseau, serveur), la saisie est conservée. Bouton absent
|
||||
sans `apiUrl` ; réception à activer sur le serveur (`server/deploy/LISEZMOI.md`, étape 3 octies).
|
||||
* **Page « Formulaires des demandes »** (permission `feedback.forms`) : types de demande (1 à 8 : ajout, suppression,
|
||||
ordre d'affichage, icône d'origine ou importée depuis un fichier .ico 64 × 64, captures d'écran autorisées ou non) et
|
||||
leurs champs (zone de texte, texte court, liste déroulante, cases à cocher, case oui/non, étoiles), modifiables ou
|
||||
masqués, avec un aperçu identique à la fenêtre des joueurs et un contrôle en direct ; communs à tous les jeux, **signés** (autorisation « formulaires » du trousseau) et vérifiés par le launcher
|
||||
à l'ouverture de la fenêtre « Aide », sans aucun lien autorisé dans les libellés (`server/deploy/LISEZMOI.md`,
|
||||
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi.
|
||||
* **Page « Demandes des joueurs »** (permission `feedback.read` ; traitement : `feedback.manage`) : choix du jeu
|
||||
(nombre de nouvelles demandes sur chaque tuile), filtres (type, statut, recherche), fiche complète, statut (Nouvelle,
|
||||
En cours, Traitée, Rejetée, Doublon), note interne, suppression, captures d'écran (miniature, agrandissement). Une
|
||||
@@ -621,7 +640,9 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
|
||||
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
|
||||
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
|
||||
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
|
||||
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
|
||||
reste jouable), et un catalogue refusé est ignoré. Le trousseau est retéléchargé sans redémarrer le launcher quand
|
||||
un contenu est signé par une clé absente ou non autorisée pour ce contenu (au plus une fois par minute), et au plus
|
||||
toutes les 30 minutes (une clé retirée n'est plus acceptée par un launcher laissé ouvert). Après le retrait d'une clé compromise, relevez
|
||||
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
|
||||
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
|
||||
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
|
||||
|
||||
+174
-3
@@ -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
|
||||
@@ -782,6 +784,162 @@ recherche, liste et fiche en deux colonnes (texte brut, sélectionnable, jamais
|
||||
(`feedback.manage`), suppression confirmée ; bandeaux « réception désactivée » et « plafond atteint ». Tableau de bord :
|
||||
carte « Demandes des joueurs » (nouvelles demandes par jeu).
|
||||
|
||||
### 11 sexies. Formulaires des demandes modifiables (7e ✅)
|
||||
|
||||
**Besoin** : gérer depuis le menu les types de demande (**1 à 8** : ajout, suppression, **ordre d'affichage**, nom,
|
||||
description, icône, exemple de titre, informations techniques cochées ou non, **captures d'écran autorisées ou non**,
|
||||
type masqué) et leurs champs (ajout, retrait, ordre, libellé, aide, obligatoire, longueur, choix), **communs à tous
|
||||
les jeux**. Les types de champs proposés restent ceux ci-dessous.
|
||||
|
||||
**Icônes** (`Contracts/Feedback/FeedbackIcons`) : l'une des **quatre icônes d'origine** (`Star`, `Lightbulb`, `Bug`,
|
||||
`Forum`), ou une icône **importée depuis un fichier .ico** (extension et en-tête vérifiés ; 1 Mo au plus) qui doit
|
||||
contenir une image de **64 × 64 pixels** (PNG intégré, revérifié, ou bitmap 24/32 bits avec son masque ; 256 couleurs
|
||||
et moins refusés ; les autres tailles sont ignorées). Lecture sans bibliothèque, puis réécriture en **PNG 64 × 64
|
||||
neuf, sans métadonnées**, intégré en base64 aux formulaires (`FeedbackForm.CustomIcon`) : il est donc **couvert par la
|
||||
signature** ; serveur et launchers le revérifient (`CheckCustom` : PNG valide, exactement 64 × 64, 20 Ko au plus,
|
||||
déjà nettoyé).
|
||||
|
||||
**Captures d'écran par type** (`FeedbackForm.AllowAttachments`, vrai par défaut) : faux = section masquée dans la
|
||||
fenêtre « Aide », aucune capture envoyée, et le serveur refuse une demande qui en annonce (`FeedbackRules.Validate`).
|
||||
Mêmes règles qu'avant sinon (3 au plus, conversion, contrôles).
|
||||
|
||||
**Types de champs** (`FeedbackFieldKind`, écrits en toutes lettres) : `Multiline` (zone de texte, 20 à 2 000
|
||||
caractères), `Text` (texte court, 1 à 200), `Choice` (liste déroulante, choix unique), `Checkboxes` (cases à cocher :
|
||||
identifiants séparés par des virgules, remis dans l'ordre du formulaire), `YesNo` (`yes`/`no` ; obligatoire = doit
|
||||
être cochée), `Rating` (3, 5 ou 10 étoiles). `ShowInList` : la valeur d'un champ (liste, note ou oui/non) sert
|
||||
d'aperçu dans la liste du menu (remplace la règle figée « note » / « gravité »).
|
||||
|
||||
**Document** (`Contracts/Feedback/FeedbackFormsDocument`) : `feedback-forms.json` = format, version, **révision**,
|
||||
formulaires, signature ; révision 0 = formulaires d'origine (`FeedbackForms`, jamais publiés). Règles
|
||||
(`FeedbackFormRules.Validate`, communes au menu, au serveur et au launcher) : 1 à 8 types (identifiants
|
||||
`[a-z][a-z0-9_]{0,31}` uniques ; ordre du document = ordre d'affichage), au moins un proposé ; 15 champs au plus ;
|
||||
identifiants de champ uniques ; 2 à 20 choix ; textes déjà propres (une ligne ; aide : 4 lignes, 200 caractères), sans
|
||||
caractère invisible ni **aucun lien** (`FeedbackLinks`) ; icône d'origine parmi les quatre, icône importée contrôlée ;
|
||||
**256 Ko** au plus (icônes comprises : route `PUT v1/feedback/forms` à 264 Ko, bloc nginx dédié). Condensé signé : format, version, révision et formulaires (la date de publication n'en fait
|
||||
pas partie).
|
||||
|
||||
**Signature** : nouveau type de contenu `feedback-forms` (identifiant signé `feedback-forms`) et nouvelle autorisation
|
||||
du trousseau **`forms`** (permission `feedback.forms`, Propriétaire et Administrateur par défaut, pas l'Éditeur).
|
||||
Une autorisation inconnue mais bien formée est désormais **acceptée** dans le trousseau (sans effet : `Verify` exige
|
||||
l'autorisation exacte), pour que les prochaines n'invalident plus le trousseau des launchers ; les launchers 3.1 et
|
||||
avant refusent encore un trousseau contenant `forms` et gardent le précédent (publier la 3.2 avant de re-signer).
|
||||
|
||||
| Route | Accès | Signée | Rôle |
|
||||
|---|---|:-:|---|
|
||||
| `GET v1/feedback/forms` | administrateur connecté | | Formulaires en vigueur, publication possible, adresse publique, signataire |
|
||||
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (changements par type) |
|
||||
|
||||
**Serveur** (`Storage/FeedbackFormsStore`) : référence privée `feedback-forms.json` (+ `.bak`, historique de 20
|
||||
révisions), copie **publique** à côté du trousseau (`FeedbackFormsPublicFileFullPath`, 644, atomique ; public d'abord).
|
||||
Formulaires signés obligatoires (pas de mode « non signé »). SQLite écarté : un seul petit document, rarement modifié,
|
||||
publié en fichier de toute façon.
|
||||
|
||||
**Validation des demandes** : `FeedbackSubmission.FormRevision`. Révision 0 (launchers 3.0 / 3.1, ou formulaires
|
||||
publiés illisibles chez le joueur) : formulaires d'origine, toujours acceptés sauf type masqué depuis. Révision en
|
||||
vigueur : son formulaire, **conservé avec la demande** (`FeedbackRecord.Form`, `FeedbackDetail.Form`) pour l'afficher
|
||||
avec ses libellés d'alors. Autre révision : **409 `feedback_form_changed`** (avant le comptage des limites par
|
||||
adresse).
|
||||
|
||||
**Launcher (joueur)** : `FeedbackFormsService` (`Modules.Feedback`) lit `feedback-forms.json` (adresse déduite du
|
||||
trousseau, sinon du catalogue) **à l'ouverture de la fenêtre « Aide » seulement**, 256 Ko au plus (icônes importées comprises), 8 s au plus ;
|
||||
règles contrôlées même si signé ; signature vérifiée par `ContentTrustService.VerifyFeedbackFormsAsync` selon
|
||||
`contentSigning` (clé autorisée `forms`, date de signature non antérieure) ; révision inférieure à la copie gardée
|
||||
refusée ; copie gardée dans `feedback\forms.json` et revérifiée à la lecture. Rien de valide : formulaires d'origine.
|
||||
Fenêtre : « Chargement des formulaires… », types masqués absents, nouveaux champs (texte court, cases à cocher
|
||||
arrondies, case oui/non, 3 à 10 étoiles), aide affichée sous les champs sans zone de saisie. Sur 409 : formulaires
|
||||
relus, type reconstruit, saisie des champs de même identifiant et de même type reprise, message au joueur ; type masqué
|
||||
depuis : retour au choix du type.
|
||||
|
||||
**Menu d'administration** : la fiche d'une demande utilise le formulaire conservé avec elle (libellés, nom du type).
|
||||
|
||||
Page **« Formulaires des demandes »** (`Feedback/FeedbackFormsPanelViewModel`, `FeedbackFormEditors`, permission
|
||||
`feedback.forms`, ordre 16) : trois colonnes, types de demande (proposé / masqué, nouveau / modifié ; « Ajouter un
|
||||
type de demande », 8 au plus, identifiant généré `type_1`… jamais celui d'un type publié ou d'origine, avec un champ
|
||||
« Message ») ; réglages du type (monter, descendre, supprimer avec confirmation — les demandes reçues restent
|
||||
consultables ; nom ; icône d'origine ou « Importer un fichier .ico… » / « Retirer l'icône importée » ; description,
|
||||
exemple de titre, informations techniques cochées par défaut, captures d'écran autorisées, masqué) et champs repliables
|
||||
(monter, descendre, retirer ; libellé, aide, obligatoire, aperçu dans la liste, longueur, nombre d'étoiles, choix
|
||||
ordonnés) ; « Ajouter un champ » par type, identifiant généré et fixe (`texte_1`, `cases_2`…, jamais un identifiant
|
||||
publié) ; **aperçu** rendu avec les modèles de la fenêtre « Aide » (`Themes/FeedbackForms.xaml`, partagé). Contrôle
|
||||
en direct avec `FeedbackFormRules` (publication bloquée tant qu'il reste une erreur) ; avertissements sans refus :
|
||||
libellé qui semble demander une donnée personnelle (`PersonalDataWords` : e-mail, mot de passe, téléphone…), type sans
|
||||
champ. « Publier » : confirmation (types masqués rappelés), contrôle du trousseau publié (`PlayerSigningCheck` :
|
||||
autorisation « formulaires »), signature par la clé de ce PC, requête signée, révision attendue (409 : bandeau
|
||||
« 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 :
|
||||
@@ -810,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
|
||||
@@ -853,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
|
||||
@@ -885,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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -239,6 +239,24 @@ public sealed class LauncherApiOptions
|
||||
/// <summary>Adresse publique du trousseau, calculée par <see cref="Validate"/>.</summary>
|
||||
public string KeyringPublicUrl { get; private set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Fichier public des formulaires des demandes (feedback-forms.json, à côté du trousseau ; null : publication
|
||||
/// désactivée), calculé par <see cref="Validate"/>. Les launchers le lisent à la même adresse que le trousseau.
|
||||
/// </summary>
|
||||
public string? FeedbackFormsPublicFileFullPath { get; private set; }
|
||||
|
||||
/// <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; }
|
||||
|
||||
@@ -388,8 +406,30 @@ public sealed class LauncherApiOptions
|
||||
{
|
||||
throw new InvalidOperationException($"{Section}:Keyring:PublicUrl doit être une adresse https://.");
|
||||
}
|
||||
|
||||
// Formulaires des demandes : toujours à côté du trousseau (les launchers en déduisent l'adresse).
|
||||
FeedbackFormsPublicFileFullPath = KeyringPublicFileFullPath is { } keyringFile
|
||||
? 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);
|
||||
|
||||
@@ -4,6 +4,7 @@ using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using GameLauncher.Server.Security;
|
||||
using GameLauncher.Server.Storage;
|
||||
@@ -20,6 +21,10 @@ namespace GameLauncher.Server.Modules.Feedback;
|
||||
/// identifiant du PC ne sont enregistrés avec la demande, ni dans l'audit de son envoi.
|
||||
/// • Administrateurs : lecture (feedback.read, consultations inscrites au journal), statut et note interne,
|
||||
/// suppression (feedback.manage, requêtes signées). Pas de réponse au joueur (module futur « comptes »).
|
||||
/// • Formulaires (communs à tous les jeux, <see cref="FeedbackFormsStore"/>) : lecture par tout administrateur (contenu
|
||||
/// public), publication par feedback.forms, requête signée ET formulaires signés par la clé de la session
|
||||
/// (autorisation « forms » du trousseau, vérifiée par les launchers). Chaque demande est validée avec le formulaire
|
||||
/// de la révision qu'elle annonce (0 : formulaires d'origine) ; une révision dépassée répond 409.
|
||||
/// </summary>
|
||||
public sealed class FeedbackModule : IApiModule
|
||||
{
|
||||
@@ -28,6 +33,7 @@ public sealed class FeedbackModule : IApiModule
|
||||
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
|
||||
{
|
||||
services.AddSingleton<FeedbackStore>();
|
||||
services.AddSingleton<FeedbackFormsStore>();
|
||||
services.AddSingleton<FeedbackGuard>();
|
||||
services.AddSingleton<FeedbackConsultations>();
|
||||
}
|
||||
@@ -41,6 +47,9 @@ public sealed class FeedbackModule : IApiModule
|
||||
.WithBodyLimit(FeedbackImages.MaxRequestBytes);
|
||||
|
||||
api.MapGet(ApiRoutes.FeedbackOverview, Overview).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapGet(ApiRoutes.FeedbackForms, GetForms).RequireAdmin();
|
||||
api.MapPut(ApiRoutes.FeedbackForms, PublishForms).RequirePermission(AdminPermissions.FeedbackForms).RequireSignedRequest()
|
||||
.WithBodyLimit(FeedbackFormRules.MaxRequestBytes); // icônes importées comprises (bloc nginx dédié)
|
||||
api.MapGet(ApiRoutes.GameFeedbackPattern, List).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapGet(ApiRoutes.GameFeedbackItemPattern, Get).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
api.MapGet(ApiRoutes.GameFeedbackAttachmentPattern, GetAttachment).RequirePermission(AdminPermissions.FeedbackRead);
|
||||
@@ -56,7 +65,8 @@ public sealed class FeedbackModule : IApiModule
|
||||
private static IResult Challenge(FeedbackStore store, FeedbackGuard guard) =>
|
||||
store.Enabled ? Results.Json(guard.CreateChallenge(), ApiJson.Options) : NotConfigured();
|
||||
|
||||
private static IResult Submit(HttpContext context, FeedbackStore store, FeedbackGuard guard, AuditLog audit, TimeProvider time)
|
||||
private static IResult Submit(HttpContext context, FeedbackStore store, FeedbackFormsStore forms, FeedbackGuard guard, AuditLog audit,
|
||||
TimeProvider time)
|
||||
{
|
||||
if (!store.Enabled) return NotConfigured();
|
||||
|
||||
@@ -85,9 +95,22 @@ public sealed class FeedbackModule : IApiModule
|
||||
return ApiResults.BadRequest("Ce jeu n'accepte pas de demandes sur ce serveur.");
|
||||
}
|
||||
|
||||
// 3. Contenu (mêmes règles que le launcher).
|
||||
// 3. Formulaire de la révision annoncée (409 si les formulaires ont été republiés pendant la saisie), puis contenu
|
||||
// (mêmes règles que le launcher).
|
||||
FeedbackForm form;
|
||||
bool snapshot;
|
||||
try
|
||||
{
|
||||
(form, snapshot) = forms.Resolve(submission.FormRevision, submission.Kind);
|
||||
}
|
||||
catch (ApiRejection ex)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null,
|
||||
ex.Status == 409 ? $"formulaire dépassé (révision {submission.FormRevision})" : "type de demande refusé");
|
||||
throw;
|
||||
}
|
||||
var errors = new List<string>();
|
||||
var clean = FeedbackRules.Validate(submission with { GameId = gameId }, errors);
|
||||
var clean = FeedbackRules.Validate(submission with { GameId = gameId }, form, errors);
|
||||
if (clean is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null, $"contenu refusé ({errors.Count} erreur(s))");
|
||||
@@ -102,7 +125,7 @@ public sealed class FeedbackModule : IApiModule
|
||||
"Vous avez envoyé plusieurs demandes récemment : patientez un peu avant d'en envoyer une autre.");
|
||||
}
|
||||
var now = time.GetUtcNow();
|
||||
var record = store.Add(gameId, clean, now);
|
||||
var record = store.Add(gameId, clean, now, snapshot ? form : null);
|
||||
if (record is null)
|
||||
{
|
||||
audit.WriteAnonymousFailure("feedback.submit", null, null, $"plafond atteint ({store.FullReason(gameId, now) ?? "?"})");
|
||||
@@ -112,7 +135,8 @@ public sealed class FeedbackModule : IApiModule
|
||||
|
||||
// Audit SANS adresse IP : l'audit ne doit pas permettre de relier une demande « anonyme » à une adresse.
|
||||
audit.Write("feedback.submit", AuditResults.Ok, target: record.Reference,
|
||||
details: $"jeu {gameId} ; {FeedbackForms.KindLabel(record.Kind)}" + (record.Tech is null ? "" : " ; informations techniques jointes") +
|
||||
details: $"jeu {gameId} ; {KindLabel(record)}" +
|
||||
(record.FormRevision > 0 ? $" ; formulaires révision {record.FormRevision}" : "") + (record.Tech is null ? "" : " ; informations techniques jointes") +
|
||||
(record.AttachmentCount > 0 ? $" ; {record.AttachmentCount} capture(s) annoncée(s)" : ""));
|
||||
return Results.Json(new FeedbackSubmitted
|
||||
{
|
||||
@@ -151,6 +175,58 @@ public sealed class FeedbackModule : IApiModule
|
||||
return Results.Json(new FeedbackAttachmentStored { Index = index }, ApiJson.Options);
|
||||
}
|
||||
|
||||
/// <summary>Nom du type tel qu'à l'envoi (formulaires publiés), sinon nom d'origine.</summary>
|
||||
private static string KindLabel(FeedbackRecord record) => record.Form?.Label ?? FeedbackForms.KindLabel(record.Kind);
|
||||
|
||||
// ------------------------------------------------------------------ Formulaires (communs à tous les jeux)
|
||||
|
||||
private static IResult GetForms(FeedbackFormsStore forms, AdminStore admins) => Results.Json(FormsState(forms, admins), ApiJson.Options);
|
||||
|
||||
private static FeedbackFormsState FormsState(FeedbackFormsStore forms, AdminStore admins)
|
||||
{
|
||||
var current = forms.Current;
|
||||
return new FeedbackFormsState
|
||||
{
|
||||
Document = current,
|
||||
PublishingEnabled = forms.PublishingEnabled,
|
||||
PublicUrl = forms.PublicUrl,
|
||||
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publication des formulaires : requête signée (permission feedback.forms) ET formulaires signés par la clé de la
|
||||
/// session sur leur condensé (les launchers revérifient avec le trousseau : autorisation « forms »), règles complètes,
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
|
||||
/// </summary>
|
||||
private static IResult PublishForms(FeedbackFormsPublishRequest request, HttpContext context, FeedbackFormsStore forms, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, ILogger<FeedbackModule> logger)
|
||||
{
|
||||
if (request.Document is null) return ApiResults.BadRequest("Formulaires absents.");
|
||||
var document = request.Document with { UpdatedAt = null };
|
||||
var actor = context.Admin();
|
||||
if (document.Signature is not { } signature)
|
||||
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Les formulaires doivent être signés.");
|
||||
ContentSignatures.RequireFromActor(signature, ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId,
|
||||
FeedbackFormRules.Digest(document), actor, time.GetUtcNow());
|
||||
|
||||
FeedbackFormsDocument published, previous;
|
||||
try
|
||||
{
|
||||
(published, previous) = forms.Publish(document, request.BaseRevision, time.GetUtcNow());
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Publication des formulaires des demandes");
|
||||
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
|
||||
"Le serveur n'a pas pu écrire les formulaires publics : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
|
||||
}
|
||||
|
||||
audit.Write("feedback.forms", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{FeedbackFormsStore.Describe(previous, published)} ; signé (clé {signature.KeyId})");
|
||||
return Results.Json(FormsState(forms, admins), ApiJson.Options);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Administrateurs
|
||||
|
||||
private static IResult Overview(FeedbackStore store, IOptions<LauncherApiOptions> options, TimeProvider time)
|
||||
@@ -172,7 +248,7 @@ public sealed class FeedbackModule : IApiModule
|
||||
{
|
||||
var game = store.ResolveGame(gameId);
|
||||
if (game is null) return ApiResults.NotFound("Ce jeu ne reçoit pas de demandes sur ce serveur.");
|
||||
if (kind is not null && FeedbackForms.Find(kind) is null) return ApiResults.BadRequest("Type de demande inconnu.");
|
||||
if (kind is not null && !FeedbackFormRules.IsValidId(kind)) return ApiResults.BadRequest("Type de demande inconnu.");
|
||||
if (!FeedbackStore.IsValidStatusFilter(status)) return ApiResults.BadRequest("Statut inconnu.");
|
||||
if (cursor is { Length: > 60 }) return ApiResults.BadRequest("Curseur invalide.");
|
||||
|
||||
@@ -194,7 +270,7 @@ public sealed class FeedbackModule : IApiModule
|
||||
// demande et par clé toutes les 10 minutes (sans le contenu).
|
||||
if (consultations.ShouldRecord(actor.Key.KeyId, "item:" + record.Id, continuation: true))
|
||||
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
record.Reference, $"jeu {game} ; {FeedbackForms.KindLabel(record.Kind)}");
|
||||
record.Reference, $"jeu {game} ; {KindLabel(record)}");
|
||||
return Results.Json(record.ToDetail(), ApiJson.Options);
|
||||
}
|
||||
|
||||
@@ -242,7 +318,7 @@ public sealed class FeedbackModule : IApiModule
|
||||
var actor = context.Admin();
|
||||
var deleted = store.Delete(game, id);
|
||||
audit.Write("feedback.delete", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
deleted.Reference, $"jeu {game} ; {FeedbackForms.KindLabel(deleted.Kind)} ; {FeedbackStatuses.Label(deleted.Status)}");
|
||||
deleted.Reference, $"jeu {game} ; {KindLabel(deleted)} ; {FeedbackStatuses.Label(deleted.Status)}");
|
||||
return Results.NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,189 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires des demandes des joueurs (communs à tous les jeux), publiés depuis le menu d'administration.
|
||||
/// • Référence : feedback-forms.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és (autorisation « forms » 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é : les formulaires d'origine (révision 0) s'appliquent.
|
||||
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFormsStore
|
||||
{
|
||||
public const string FileName = "feedback-forms.json";
|
||||
private const string HistoryFolder = "feedback-forms-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 FeedbackFormsDocument? _current;
|
||||
|
||||
public FeedbackFormsStore(IOptions<LauncherApiOptions> options)
|
||||
: this(options.Value.DataDirectoryFullPath, options.Value.FeedbackFormsPublicFileFullPath, options.Value.FeedbackFormsPublicUrl) { }
|
||||
|
||||
public FeedbackFormsStore(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>Formulaires en vigueur : ceux publiés, sinon ceux d'origine (révision 0).</summary>
|
||||
public FeedbackFormsDocument Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock) return _current ??= Read() ?? FeedbackFormRules.Defaults();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formulaire à utiliser pour valider une demande, ou la raison du refus. Révision 0 (formulaires d'origine) :
|
||||
/// toujours acceptée (launchers 3.0 / 3.1, formulaires publiés illisibles chez le joueur), sauf pour un type
|
||||
/// masqué ou supprimé depuis. Autre révision : seule celle en vigueur est acceptée (409 : le launcher recharge les
|
||||
/// formulaires).
|
||||
/// </summary>
|
||||
/// <returns>Le formulaire du type, et s'il faut le conserver avec la demande (formulaires publiés).</returns>
|
||||
public (FeedbackForm Form, bool Snapshot) Resolve(long revision, string? kind)
|
||||
{
|
||||
var current = Current;
|
||||
if (current.Find(kind) is { Hidden: true } ||
|
||||
(current.Revision > 0 && current.Find(kind) is null && FeedbackForms.Find(kind) is not null))
|
||||
throw ApiRejection.BadRequest("Ce type de demande n'est plus proposé.");
|
||||
if (revision == 0)
|
||||
return (FeedbackForms.Find(kind) ?? throw ApiRejection.BadRequest("Type de demande inconnu."), false);
|
||||
if (revision != current.Revision)
|
||||
throw new ApiRejection(409, ApiErrorCodes.FeedbackFormChanged,
|
||||
"Le formulaire vient d'être mis à jour : vérifiez votre saisie, puis envoyez de nouveau.");
|
||||
return (current.Find(kind) ?? throw ApiRejection.BadRequest("Type de demande inconnu."), current.Revision > 0);
|
||||
}
|
||||
|
||||
/// <summary>Formulaires publiés (null : aucun).</summary>
|
||||
private FeedbackFormsDocument? Read()
|
||||
{
|
||||
if (!File.Exists(_path)) return null;
|
||||
return FeedbackFormRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publie des formulaires signés (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.
|
||||
/// </summary>
|
||||
/// <returns>Formulaires publiés (avec la date) et ceux d'avant.</returns>
|
||||
public (FeedbackFormsDocument Published, FeedbackFormsDocument Previous) Publish(FeedbackFormsDocument document, long baseRevision,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (_publicFile is null)
|
||||
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
|
||||
"Publication des formulaires non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
|
||||
"LauncherApi:Catalog:PublicFile) est nécessaire, les formulaires sont publiés à côté.");
|
||||
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
|
||||
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Formulaires non signés.");
|
||||
var errors = new List<string>();
|
||||
if (!FeedbackFormRules.Validate(document, errors))
|
||||
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
|
||||
|
||||
lock (_lock)
|
||||
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
|
||||
{
|
||||
var previous = Read() ?? FeedbackFormRules.Defaults();
|
||||
if (baseRevision != previous.Revision)
|
||||
throw ApiRejection.Conflict(
|
||||
$"Les formulaires ont été publiés entre-temps (révision {previous.Revision}) : rechargez la page, puis refaites vos modifications.",
|
||||
ApiErrorCodes.RevisionConflict);
|
||||
if (document.Revision != previous.Revision + 1)
|
||||
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
|
||||
|
||||
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
|
||||
var json = FeedbackFormRules.Serialize(published);
|
||||
if (Encoding.UTF8.GetByteCount(json) > FeedbackFormRules.MaxBytes)
|
||||
throw ApiRejection.BadRequest($"Formulaires trop volumineux (au plus {FeedbackFormRules.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, "formulaires des demandes");
|
||||
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, $"feedback-forms-{revision:D6}.json"), json, keepBackup: false);
|
||||
foreach (var old in Directory.GetFiles(_historyDirectory, "feedback-forms-*.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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Résumé des changements pour l'audit (types ajoutés, supprimés, réordonnés, masqués ou réaffichés ; champs ajoutés,
|
||||
/// retirés, modifiés ; captures d'écran, icône).
|
||||
/// </summary>
|
||||
public static string Describe(FeedbackFormsDocument before, FeedbackFormsDocument after)
|
||||
{
|
||||
var changes = new List<string>();
|
||||
var added = after.Forms.Where(f => before.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
|
||||
var removed = before.Forms.Where(f => after.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
|
||||
if (added.Count > 0) changes.Add($"type(s) ajouté(s) : {string.Join(", ", added)}");
|
||||
if (removed.Count > 0) changes.Add($"type(s) supprimé(s) : {string.Join(", ", removed)}");
|
||||
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
|
||||
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) changes.Add("ordre des types modifié");
|
||||
foreach (var form in after.Forms)
|
||||
{
|
||||
var old = before.Find(form.Kind);
|
||||
if (old is null) continue;
|
||||
var parts = new List<string>();
|
||||
if (old.Hidden != form.Hidden) parts.Add(form.Hidden ? "masqué" : "de nouveau proposé");
|
||||
if (old.Label != form.Label || old.Description != form.Description || old.TitleHint != form.TitleHint ||
|
||||
old.TechByDefault != form.TechByDefault)
|
||||
parts.Add("présentation modifiée");
|
||||
if (old.IconKey != form.IconKey || old.CustomIcon != form.CustomIcon)
|
||||
parts.Add(form.CustomIcon is null ? "icône d'origine" : "icône importée");
|
||||
if (old.AllowAttachments != form.AllowAttachments)
|
||||
parts.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
|
||||
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var addedFields = newIds.Count(id => !oldIds.Contains(id));
|
||||
var removedFields = oldIds.Count(id => !newIds.Contains(id));
|
||||
var changed = form.Fields.Count(f => old.Field(f.Id) is { } o &&
|
||||
JsonSerializer.Serialize(o) != JsonSerializer.Serialize(f));
|
||||
if (addedFields > 0) parts.Add($"+{addedFields} champ(s)");
|
||||
if (removedFields > 0) parts.Add($"-{removedFields} champ(s)");
|
||||
if (changed > 0) parts.Add($"{changed} champ(s) modifié(s)");
|
||||
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
|
||||
parts.Add("ordre modifié");
|
||||
var name = old.Label == form.Label ? old.Label : $"{old.Label} → « {form.Label} »";
|
||||
if (parts.Count > 0) changes.Add($"{name} : {string.Join(", ", parts)}");
|
||||
}
|
||||
return changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,13 @@ public sealed record FeedbackRecord
|
||||
public string GameId { get; init; } = "";
|
||||
public string Kind { get; init; } = "";
|
||||
public int FormVersion { get; init; }
|
||||
|
||||
/// <summary>Révision des formulaires utilisés (0 : formulaires d'origine).</summary>
|
||||
public long FormRevision { get; init; }
|
||||
|
||||
/// <summary>Formulaire tel qu'à l'envoi (formulaires publiés depuis le menu) ; null : formulaires d'origine.</summary>
|
||||
public FeedbackForm? Form { get; init; }
|
||||
|
||||
public DateTimeOffset ReceivedAt { get; init; }
|
||||
public string Title { get; init; } = "";
|
||||
public Dictionary<string, string> Fields { get; init; } = new();
|
||||
@@ -47,13 +54,7 @@ public sealed record FeedbackRecord
|
||||
Status = Status,
|
||||
ReceivedAt = ReceivedAt,
|
||||
StatusChangedAt = StatusChangedAt,
|
||||
Badge = Kind switch
|
||||
{
|
||||
FeedbackKinds.Review when Fields.TryGetValue("rating", out var rating) => $"{rating}/5",
|
||||
FeedbackKinds.Bug when Fields.TryGetValue("severity", out var severity) =>
|
||||
FeedbackForms.Find(FeedbackKinds.Bug)?.Field("severity")?.DisplayValue(severity),
|
||||
_ => null
|
||||
},
|
||||
Badge = (Form ?? FeedbackForms.Find(Kind))?.Badge(Fields),
|
||||
HasNote = !string.IsNullOrEmpty(AdminNote),
|
||||
HasLinks = FeedbackLinks.Contains(Title + "\n" + string.Join("\n", Fields.Values)),
|
||||
AttachmentCount = Attachments.Count,
|
||||
@@ -64,6 +65,8 @@ public sealed record FeedbackRecord
|
||||
{
|
||||
Summary = ToSummary(),
|
||||
FormVersion = FormVersion,
|
||||
FormRevision = FormRevision,
|
||||
Form = Form,
|
||||
Fields = Fields,
|
||||
Tech = Tech,
|
||||
Attachments = Attachments.OrderBy(a => a.Index).ToList(),
|
||||
@@ -145,7 +148,8 @@ public sealed class FeedbackStore
|
||||
}
|
||||
|
||||
/// <summary>Enregistre une demande déjà validée (<see cref="FeedbackRules.Validate"/>). Null : plafond atteint.</summary>
|
||||
public FeedbackRecord? Add(string gameId, FeedbackSubmission submission, DateTimeOffset now)
|
||||
/// <param name="form">Formulaire utilisé, conservé avec la demande (null : formulaires d'origine).</param>
|
||||
public FeedbackRecord? Add(string gameId, FeedbackSubmission submission, DateTimeOffset now, FeedbackForm? form = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
@@ -164,6 +168,8 @@ public sealed class FeedbackStore
|
||||
GameId = gameId,
|
||||
Kind = submission.Kind,
|
||||
FormVersion = submission.FormVersion,
|
||||
FormRevision = form is null ? 0 : submission.FormRevision,
|
||||
Form = form,
|
||||
ReceivedAt = now,
|
||||
Title = submission.Title,
|
||||
Fields = new Dictionary<string, string>(submission.Fields, StringComparer.Ordinal),
|
||||
|
||||
@@ -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
|
||||
@@ -415,6 +418,89 @@ Un fichier d'audit mensuel est supprimé quand tous ses jours dépassent la dur
|
||||
donc compter quelques semaines de plus). Pense aussi à borner les journaux du système (voir la fiche RGPD) :
|
||||
`journald` (`MaxRetentionSec=1year` dans `/etc/systemd/journald.conf`) et nginx (`logrotate`).
|
||||
|
||||
## 3 nonies. Formulaires des demandes modifiables depuis le menu (PC et VPS)
|
||||
|
||||
Les types de demande du bouton **Aide** (avis, demande d'évolution, bug, autre au départ ; 1 à 8) se gèrent depuis le
|
||||
menu d'administration : ajout, suppression, ordre d'affichage, nom, description, icône (l'une des quatre d'origine ou
|
||||
un fichier **.ico** contenant une image 64 × 64), captures d'écran autorisées ou non, champs (zone de texte, texte
|
||||
court, liste déroulante, cases à cocher, case oui/non, étoiles), types masqués. Les formulaires sont **communs à tous les jeux** et **signés** : un serveur
|
||||
compromis ne peut pas faire afficher aux joueurs un faux formulaire (« entrez votre mot de passe »). Les libellés et
|
||||
aides ne peuvent contenir **aucun lien**.
|
||||
|
||||
Prérequis : étapes 3 bis (catalogue), 3 quinquies (trousseau) et 3 octies (demandes).
|
||||
|
||||
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : les formulaires sont
|
||||
écrits à côté du trousseau (`/srv/exovival/www/catalog/feedback-forms.json`, 644).
|
||||
2. **nginx** : ajoute
|
||||
- le bloc `location = /catalog/feedback-forms.json { … }` de `server/deploy/nginx-catalog.conf`, à côté de celui
|
||||
du trousseau (lecture par les launchers) ;
|
||||
- le bloc `location = /api/v1/feedback/forms { … }` de `server/deploy/nginx-api.conf` (publication depuis le
|
||||
menu : formulaires et icônes importées, 300 Ko au plus ; sans lui, une publication de plus de 64 Ko est refusée) ;
|
||||
|
||||
puis `sudo nginx -t && sudo systemctl reload nginx`.
|
||||
3. **Launcher 3.2** : publie-le (mise à jour signée habituelle) **avant** l'étape 4, et laisse aux joueurs le temps de
|
||||
le recevoir. Les launchers plus anciens ne connaissent pas la nouvelle autorisation « formulaires » du trousseau :
|
||||
ils refusent le nouveau trousseau et gardent le précédent (aucune gêne pour jouer, mais un appareil ajouté au
|
||||
trousseau en même temps ne serait pas reconnu par eux).
|
||||
4. **Permission et trousseau** :
|
||||
1. page **Groupes et permissions** : donne `feedback.forms` (« Formulaires des demandes ») au groupe qui doit modifier
|
||||
les formulaires (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 **« formulaires des demandes »** des
|
||||
appareils concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
|
||||
5. **Publier** depuis le menu, page **« Formulaires des demandes »** (modifier, masquer, aperçu, « Publier »). Les launchers lisent le fichier à l'ouverture de
|
||||
la fenêtre « Aide » et vérifient la signature comme pour le catalogue (`contentSigning`) ; s'ils ne peuvent pas
|
||||
(hors ligne, signature refusée), ils proposent les formulaires d'origine, toujours acceptés par le serveur.
|
||||
|
||||
Compatibilité : chaque demande annonce la révision des formulaires utilisés. Les launchers 3.0 / 3.1 (révision 0,
|
||||
formulaires d'origine) restent acceptés, sauf pour un type masqué. Si les formulaires sont republiés pendant qu'un
|
||||
joueur remplit le sien, le serveur répond 409 : le launcher 3.2 relit les formulaires et garde la saisie des champs
|
||||
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 :
|
||||
@@ -543,9 +629,16 @@ recréer les administrateurs.
|
||||
├─ publications/ publications en cours (version, fichiers restant à envoyer)
|
||||
├─ feedback/<jeu>/AAAA-MM/<id>.json demandes des joueurs (une par fichier, 600 ; purge automatique)
|
||||
│ 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é)
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
|
||||
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
|
||||
# (les blocs ci-dessous ; le deuxième, envoi des images du catalogue, voir LISEZMOI.md étape 3 septies ;
|
||||
# le troisième, demandes des joueurs, voir LISEZMOI.md étape 3 octies)
|
||||
# les suivants, demandes des joueurs, LISEZMOI.md étape 3 octies ; le dernier, formulaires des demandes,
|
||||
# LISEZMOI.md étape 3 nonies)
|
||||
#
|
||||
# Puis : sudo nginx -t && sudo systemctl reload nginx
|
||||
# =============================================================================
|
||||
@@ -119,3 +120,24 @@ location ^~ /api/v1/feedback/attachments/ {
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# Publication des formulaires des demandes (page « Formulaires des demandes », administrateurs) : formulaires signés
|
||||
# avec leurs icônes importées (256 Ko au plus). Correspondance EXACTE : la lecture (GET) passe aussi par ce bloc,
|
||||
# avec les limites de débit des administrateurs ; la route exige une session, feedback.forms et une requête signée.
|
||||
location = /api/v1/feedback/forms {
|
||||
limit_req zone=api_par_ip burst=20 nodelay;
|
||||
limit_req_status 429;
|
||||
|
||||
client_max_body_size 300k;
|
||||
|
||||
proxy_pass http://127.0.0.1:5080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
# =============================================================================
|
||||
# Catalogue des jeux et trousseau (écrits par la Launcher API) — voir LISEZMOI.md, étapes 3 bis et 3 quinquies :
|
||||
# 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)
|
||||
@@ -26,3 +27,21 @@ location = /catalog/keyring.json {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# Formulaires des demandes (bouton « Aide ») : publiés depuis le menu, signés (autorisation « forms » du trousseau),
|
||||
# lus par les launchers à l'ouverture de la fenêtre « Aide ».
|
||||
location = /catalog/feedback-forms.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;
|
||||
}
|
||||
|
||||
# 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;
|
||||
|
||||
@@ -295,6 +296,57 @@ public sealed class AdminSession : IAsyncDisposable
|
||||
public Task DeleteFeedbackAsync(string gameId, string id, CancellationToken ct = default) =>
|
||||
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)), null, ct);
|
||||
|
||||
/// <summary>Formulaires des demandes en vigueur (communs à tous les jeux ; révision 0 : formulaires d'origine).</summary>
|
||||
public Task<FeedbackFormsState> GetFeedbackFormsAsync(CancellationToken ct = default) =>
|
||||
GetAsync<FeedbackFormsState>(ApiRoutes.FeedbackForms, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Publie les formulaires : révision <paramref name="baseRevision"/> + 1, signés par la clé de cette session (autorisation
|
||||
/// « forms » du trousseau requise chez les joueurs), puis envoyés en requête signée.
|
||||
/// </summary>
|
||||
public async Task<FeedbackFormsState> PublishFeedbackFormsAsync(IReadOnlyList<FeedbackForm> forms, long baseRevision,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var document = new FeedbackFormsDocument { Revision = baseRevision + 1, Forms = forms.ToList() };
|
||||
var errors = new List<string>();
|
||||
if (!FeedbackFormRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(forms));
|
||||
var signature = await SignContentAsync(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(document), ct)
|
||||
.ConfigureAwait(false);
|
||||
return await SendSignedAsync<FeedbackFormsState>(HttpMethod.Put, ApiRoutes.FeedbackForms,
|
||||
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,
|
||||
|
||||
@@ -123,6 +123,9 @@ public static class ApiRoutes
|
||||
/// <summary>Compteurs par jeu (feedback.read).</summary>
|
||||
public const string FeedbackOverview = "v1/feedback/overview";
|
||||
|
||||
/// <summary>Formulaires des demandes : lecture (feedback.forms ou feedback.read), publication signée (PUT, feedback.forms).</summary>
|
||||
public const string FeedbackForms = "v1/feedback/forms";
|
||||
|
||||
public const string GameFeedbackPattern = "v1/games/{gameId}/feedback";
|
||||
public const string GameFeedbackItemPattern = "v1/games/{gameId}/feedback/{id}";
|
||||
|
||||
@@ -144,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}";
|
||||
|
||||
@@ -72,6 +72,9 @@ public static class ApiErrorCodes
|
||||
/// <summary>Demandes des joueurs : plafond atteint (nombre, taille, espace disque), envoi refusé pour l'instant.</summary>
|
||||
public const string FeedbackFull = "feedback_full";
|
||||
|
||||
/// <summary>Les formulaires des demandes ont été republiés pendant la saisie : le launcher les recharge.</summary>
|
||||
public const string FeedbackFormChanged = "feedback_form_changed";
|
||||
|
||||
/// <summary>Demandes des joueurs : défi anti-spam absent, expiré, déjà utilisé ou mal résolu.</summary>
|
||||
public const string ProofInvalid = "proof_invalid";
|
||||
}
|
||||
@@ -42,6 +42,13 @@ public sealed record FeedbackSubmission
|
||||
public string GameId { get; init; } = "";
|
||||
public string Kind { get; init; } = "";
|
||||
public int FormVersion { get; init; } = FeedbackForms.CurrentVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Révision des formulaires utilisés (<see cref="FeedbackFormsDocument.Revision"/>) ; 0 : formulaires d'origine
|
||||
/// (launchers 3.0 / 3.1, ou formulaires publiés illisibles). Une autre révision que celle en vigueur : 409.
|
||||
/// </summary>
|
||||
public long FormRevision { get; init; }
|
||||
|
||||
public string Title { get; init; } = "";
|
||||
|
||||
/// <summary>Valeurs des champs du formulaire (identifiant du champ → valeur).</summary>
|
||||
@@ -170,6 +177,16 @@ public sealed record FeedbackDetail
|
||||
{
|
||||
public FeedbackSummary Summary { get; init; } = new();
|
||||
public int FormVersion { get; init; }
|
||||
|
||||
/// <summary>Révision des formulaires avec laquelle la demande a été envoyée (0 : formulaires d'origine).</summary>
|
||||
public long FormRevision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Formulaire tel qu'il était à l'envoi (libellés, types, choix) : une demande reste lisible même si le formulaire a
|
||||
/// changé depuis. Null : demande reçue avant la 3.2 (formulaires d'origine).
|
||||
/// </summary>
|
||||
public FeedbackForm? Form { get; init; }
|
||||
|
||||
public Dictionary<string, string> Fields { get; init; } = new();
|
||||
public FeedbackTechInfo? Tech { get; init; }
|
||||
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>Type d'un champ de formulaire.</summary>
|
||||
/// <summary>Type d'un champ de formulaire (écrit en toutes lettres dans le fichier des formulaires).</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<FeedbackFieldKind>))]
|
||||
public enum FeedbackFieldKind
|
||||
{
|
||||
/// <summary>Texte libre sur plusieurs lignes (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
|
||||
/// <summary>Zone de texte sur plusieurs lignes (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
|
||||
Multiline,
|
||||
|
||||
/// <summary>Un choix parmi <see cref="FeedbackField.Options"/> (valeur : identifiant de l'option).</summary>
|
||||
/// <summary>Liste déroulante : un choix parmi <see cref="FeedbackField.Options"/> (valeur : identifiant de l'option).</summary>
|
||||
Choice,
|
||||
|
||||
/// <summary>Note de 1 à 5 (valeur : « 1 » à « 5 »).</summary>
|
||||
Rating
|
||||
/// <summary>Note en étoiles, de 1 à <see cref="FeedbackField.RatingMax"/> (valeur : « 1 », « 2 »…).</summary>
|
||||
Rating,
|
||||
|
||||
/// <summary>Texte court sur une ligne (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
|
||||
Text,
|
||||
|
||||
/// <summary>Cases à cocher : plusieurs choix (valeur : identifiants des options cochées, séparés par des virgules).</summary>
|
||||
Checkboxes,
|
||||
|
||||
/// <summary>Case oui/non (valeur : « yes » ou « no » ; obligatoire = doit être cochée).</summary>
|
||||
YesNo
|
||||
}
|
||||
|
||||
/// <summary>Option d'un champ à choix : identifiant stable (envoyé au serveur) et libellé affiché.</summary>
|
||||
@@ -20,6 +32,8 @@ public sealed record FeedbackOption(string Id, string Label);
|
||||
/// <param name="Id">Identifiant stable (clé dans <see cref="FeedbackSubmission.Fields"/>).</param>
|
||||
/// <param name="Label">Libellé affiché (joueur et menu d'administration).</param>
|
||||
/// <param name="Hint">Aide sous le champ, ou texte indicatif dans la zone de saisie.</param>
|
||||
/// <param name="RatingMax">Nombre d'étoiles d'une note (3, 5 ou 10).</param>
|
||||
/// <param name="ShowInList">Valeur affichée en aperçu dans la liste des demandes du menu (un champ par formulaire au plus).</param>
|
||||
public sealed record FeedbackField(
|
||||
string Id,
|
||||
string Label,
|
||||
@@ -27,16 +41,25 @@ public sealed record FeedbackField(
|
||||
bool Required,
|
||||
string? Hint = null,
|
||||
int MaxLength = FeedbackRules.MaxMultilineLength,
|
||||
IReadOnlyList<FeedbackOption>? Options = null)
|
||||
IReadOnlyList<FeedbackOption>? Options = null,
|
||||
int RatingMax = 5,
|
||||
bool ShowInList = false)
|
||||
{
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<FeedbackOption> Choices => Options ?? Array.Empty<FeedbackOption>();
|
||||
|
||||
/// <summary>Libellé d'une valeur (option d'un choix, « 4/5 » pour une note, texte sinon).</summary>
|
||||
/// <summary>Champ à saisir au clavier (zone de texte ou texte court).</summary>
|
||||
[JsonIgnore]
|
||||
public bool IsText => Kind is FeedbackFieldKind.Multiline or FeedbackFieldKind.Text;
|
||||
|
||||
/// <summary>Libellé d'une valeur (option(s) choisie(s), « 4/5 » pour une note, « Oui »/« Non », texte sinon).</summary>
|
||||
public string DisplayValue(string? value) => Kind switch
|
||||
{
|
||||
_ when string.IsNullOrEmpty(value) => "",
|
||||
FeedbackFieldKind.Choice => Choices.FirstOrDefault(o => o.Id == value)?.Label ?? value!,
|
||||
FeedbackFieldKind.Rating => $"{value}/5",
|
||||
FeedbackFieldKind.Checkboxes => string.Join(", ", value!.Split(',').Select(id => Choices.FirstOrDefault(o => o.Id == id)?.Label ?? id)),
|
||||
FeedbackFieldKind.Rating => $"{value}/{RatingMax}",
|
||||
FeedbackFieldKind.YesNo => value == FeedbackRules.Yes ? "Oui" : "Non",
|
||||
_ => value!
|
||||
};
|
||||
}
|
||||
@@ -46,6 +69,9 @@ public sealed record FeedbackField(
|
||||
/// <param name="IconKey">Icône du launcher (ressource « Icon.<clé> »).</param>
|
||||
/// <param name="TitleHint">Texte indicatif du titre de la demande.</param>
|
||||
/// <param name="TechByDefault">« Joindre les informations techniques » coché à l'ouverture du formulaire.</param>
|
||||
/// <param name="Hidden">Type masqué : n'est plus proposé aux joueurs (les demandes déjà reçues restent consultables).</param>
|
||||
/// <param name="AllowAttachments">Captures d'écran proposées pour ce type (3 au plus, contrôlées et nettoyées).</param>
|
||||
/// <param name="CustomIcon">Icône importée (PNG 64 × 64 en base64, <see cref="FeedbackIcons"/>) ; null : <paramref name="IconKey"/>.</param>
|
||||
public sealed record FeedbackForm(
|
||||
string Kind,
|
||||
string Label,
|
||||
@@ -53,9 +79,24 @@ public sealed record FeedbackForm(
|
||||
string IconKey,
|
||||
string TitleHint,
|
||||
bool TechByDefault,
|
||||
IReadOnlyList<FeedbackField> Fields)
|
||||
IReadOnlyList<FeedbackField> Fields,
|
||||
bool Hidden = false,
|
||||
bool AllowAttachments = true,
|
||||
string? CustomIcon = null)
|
||||
{
|
||||
public FeedbackField? Field(string id) => Fields.FirstOrDefault(f => f.Id == id);
|
||||
/// <summary>Octets de l'icône importée (null : icône d'origine <see cref="IconKey"/>).</summary>
|
||||
public byte[]? CustomIconBytes() => FeedbackIcons.Decode(CustomIcon);
|
||||
|
||||
public FeedbackField? Field(string id) => (Fields ?? Array.Empty<FeedbackField>()).FirstOrDefault(f => f.Id == id);
|
||||
|
||||
/// <summary>Valeur montrée en aperçu dans la liste du menu (champ « afficher en aperçu »), ou null.</summary>
|
||||
public string? Badge(IReadOnlyDictionary<string, string>? values)
|
||||
{
|
||||
var field = (Fields ?? Array.Empty<FeedbackField>()).FirstOrDefault(f => f.ShowInList);
|
||||
return field is not null && values is not null && values.TryGetValue(field.Id, out var value) && !string.IsNullOrEmpty(value)
|
||||
? field.DisplayValue(value)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Types de demande (identifiants stables, jamais traduits).</summary>
|
||||
@@ -68,14 +109,14 @@ public static class FeedbackKinds
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires des demandes des joueurs, décrits UNE fois ici : le launcher les génère, le serveur valide chaque
|
||||
/// demande avec (<see cref="FeedbackRules"/>), le menu d'administration les utilise pour afficher les libellés.
|
||||
/// Ajouter un champ = le déclarer ici (champ facultatif de préférence : les anciens launchers continuent de
|
||||
/// fonctionner) ; changer le sens d'un champ existant = nouvel identifiant et <see cref="CurrentVersion"/> + 1.
|
||||
/// Formulaires D'ORIGINE des demandes des joueurs (révision 0). Ils servent tant qu'aucun formulaire n'a été publié
|
||||
/// depuis le menu d'administration (<see cref="FeedbackFormsDocument"/>), aux launchers qui n'ont pas pu lire les
|
||||
/// formulaires publiés, aux launchers 3.0 / 3.1, et à l'affichage des demandes reçues avant la 3.2.
|
||||
/// Ne pas les modifier : les formulaires se changent désormais depuis le menu (page « Formulaires des demandes »).
|
||||
/// </summary>
|
||||
public static class FeedbackForms
|
||||
{
|
||||
/// <summary>Version des formulaires (enregistrée avec chaque demande).</summary>
|
||||
/// <summary>Version du format des demandes (enregistrée avec chaque demande).</summary>
|
||||
public const int CurrentVersion = 1;
|
||||
|
||||
private static FeedbackOption O(string id, string label) => new(id, label);
|
||||
@@ -85,7 +126,7 @@ public static class FeedbackForms
|
||||
new FeedbackForm(FeedbackKinds.Review, "Avis", "Dites-nous ce que vous pensez du jeu.", "Star",
|
||||
"Votre avis en une phrase", TechByDefault: false, new[]
|
||||
{
|
||||
new FeedbackField("rating", "Note", FeedbackFieldKind.Rating, Required: true),
|
||||
new FeedbackField("rating", "Note", FeedbackFieldKind.Rating, Required: true, ShowInList: true),
|
||||
new FeedbackField("topic", "Sujet", FeedbackFieldKind.Choice, Required: true, Options: new[]
|
||||
{
|
||||
O("overall", "Le jeu dans son ensemble"), O("gameplay", "Gameplay"), O("graphics", "Graphismes"),
|
||||
@@ -129,7 +170,7 @@ public static class FeedbackForms
|
||||
O("performance", "Performances (lenteurs, saccades)"), O("install", "Installation ou mise à jour"),
|
||||
O("launcher", "Launcher"), O("other", "Autre")
|
||||
}),
|
||||
new FeedbackField("severity", "Gravité", FeedbackFieldKind.Choice, Required: true, Options: new[]
|
||||
new FeedbackField("severity", "Gravité", FeedbackFieldKind.Choice, Required: true, ShowInList: true, Options: new[]
|
||||
{
|
||||
O("minor", "Mineur"), O("annoying", "Gênant"), O("blocking", "Bloquant"), O("crash", "Plantage")
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires des demandes publiés depuis le menu d'administration (feedback-forms.json, communs à tous les jeux).
|
||||
/// Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée « forms » dans le
|
||||
/// trousseau : le launcher des joueurs le vérifie comme le catalogue avant d'afficher quoi que ce soit.
|
||||
/// Révision 0 = formulaires d'origine (<see cref="FeedbackForms"/>), jamais publiés.
|
||||
/// </summary>
|
||||
public sealed record FeedbackFormsDocument
|
||||
{
|
||||
public const string FormatName = "gamelauncher-feedback-forms";
|
||||
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). Envoyée avec chaque demande.</summary>
|
||||
public long Revision { get; init; }
|
||||
|
||||
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>Types de demande (1 à 8), dans l'ordre d'affichage aux joueurs, masqués ou non.</summary>
|
||||
public List<FeedbackForm> Forms { get; init; } = new();
|
||||
|
||||
public ContentSignature? Signature { get; init; }
|
||||
|
||||
public FeedbackForm? Find(string? kind) => (Forms ?? new List<FeedbackForm>()).FirstOrDefault(f => f.Kind == kind);
|
||||
|
||||
/// <summary>Types proposés aux joueurs (non masqués), dans l'ordre du document.</summary>
|
||||
[JsonIgnore]
|
||||
public IEnumerable<FeedbackForm> Visible => (Forms ?? new List<FeedbackForm>()).Where(f => f is not null && !f.Hidden);
|
||||
}
|
||||
|
||||
/// <summary>État des formulaires pour le menu d'administration (GET v1/feedback/forms).</summary>
|
||||
public sealed record FeedbackFormsState
|
||||
{
|
||||
/// <summary>Formulaires en vigueur (ceux d'origine, révision 0, si rien n'a été publié).</summary>
|
||||
public FeedbackFormsDocument 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; }
|
||||
|
||||
public string? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Publication des formulaires (PUT v1/feedback/forms, requête signée).</summary>
|
||||
public sealed record FeedbackFormsPublishRequest
|
||||
{
|
||||
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
|
||||
public long BaseRevision { get; init; }
|
||||
|
||||
/// <summary>Formulaires signés, révision <see cref="BaseRevision"/> + 1.</summary>
|
||||
public FeedbackFormsDocument Document { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Règles des formulaires publiés, communes au menu (contrôle avant de signer), au serveur (refus avant publication)
|
||||
/// et au launcher (refus d'un fichier hors limites, même signé). Les textes saisis par l'administrateur doivent être
|
||||
/// déjà propres (une ligne, sans caractère invisible) et ne contiennent AUCUN lien : rien ne doit pouvoir renvoyer le
|
||||
/// joueur vers un site depuis un formulaire.
|
||||
/// </summary>
|
||||
public static partial class FeedbackFormRules
|
||||
{
|
||||
/// <summary>Taille du fichier (icônes importées comprises). Publication : route dédiée de nginx et de l'API.</summary>
|
||||
public const int MaxBytes = 256 * 1024;
|
||||
|
||||
/// <summary>Corps de la requête de publication (formulaires + révision de base).</summary>
|
||||
public const int MaxRequestBytes = MaxBytes + 8 * 1024;
|
||||
|
||||
/// <summary>Nombre de types de demande (au moins un proposé aux joueurs).</summary>
|
||||
public const int MaxForms = 8;
|
||||
|
||||
public const int MaxFields = 15;
|
||||
public const int MinOptions = 2;
|
||||
public const int MaxOptions = 20;
|
||||
public const int MaxFormLabelLength = 40;
|
||||
public const int MaxDescriptionLength = 120;
|
||||
public const int MaxTitleHintLength = 80;
|
||||
public const int MaxFieldLabelLength = 80;
|
||||
public const int MaxHintLength = 200;
|
||||
public const int MaxHintLines = 4;
|
||||
public const int MaxOptionLabelLength = 60;
|
||||
public const int MaxTextLength = 200;
|
||||
public const int DefaultTextLength = 120;
|
||||
public const int MinMultilineLength = 20;
|
||||
|
||||
/// <summary>Nombres d'étoiles proposés pour une note.</summary>
|
||||
public static readonly IReadOnlyList<int> RatingSizes = new[] { 3, 5, 10 };
|
||||
|
||||
/// <summary>Icônes proposées pour un type de demande : les quatre d'origine (sinon icône importée, <see cref="FeedbackIcons"/>).</summary>
|
||||
public static IReadOnlyList<string> Icons => FeedbackIcons.DefaultIcons;
|
||||
|
||||
/// <summary>Identifiant d'un champ ou d'un choix : stable, jamais affiché (clé des valeurs envoyées).</summary>
|
||||
[GeneratedRegex(@"\A[a-z][a-z0-9_]{0,31}\z")]
|
||||
private static partial Regex IdRegex();
|
||||
|
||||
public static bool IsValidId(string? id) => id is not null && IdRegex().IsMatch(id);
|
||||
|
||||
/// <summary>Formulaires d'origine (révision 0).</summary>
|
||||
public static FeedbackFormsDocument Defaults() => new() { Revision = 0, Forms = FeedbackForms.All.ToList() };
|
||||
|
||||
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>Lit feedback-forms.json (null : vide).</summary>
|
||||
/// <exception cref="JsonException">JSON illisible.</exception>
|
||||
public static FeedbackFormsDocument? Parse(ReadOnlySpan<byte> json) => JsonSerializer.Deserialize<FeedbackFormsDocument>(json, ApiJson.Options);
|
||||
|
||||
public static string Serialize(FeedbackFormsDocument document) => JsonSerializer.Serialize(document, FileOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Condensé signé : format, version, révision et formulaires (tels quels, dans l'ordre ; valeurs vides ramenées à
|
||||
/// une forme unique). La date de publication et la signature n'en font pas partie.
|
||||
/// </summary>
|
||||
public static string Digest(FeedbackFormsDocument document)
|
||||
{
|
||||
var forms = (document.Forms ?? new List<FeedbackForm>()).Where(f => f is not null).Select(f => f with
|
||||
{
|
||||
CustomIcon = string.IsNullOrEmpty(f.CustomIcon) ? null : f.CustomIcon,
|
||||
Fields = (f.Fields ?? Array.Empty<FeedbackField>()).Where(x => x is not null).Select(x => x with
|
||||
{
|
||||
Hint = string.IsNullOrEmpty(x.Hint) ? null : x.Hint,
|
||||
Options = x.Options is { Count: > 0 } options ? options.ToList() : null
|
||||
}).ToList()
|
||||
}).ToList();
|
||||
var canonical = new CanonicalForms(document.Format ?? "", document.Version, document.Revision, forms);
|
||||
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
|
||||
}
|
||||
|
||||
private sealed record CanonicalForms(string Format, int Version, long Revision, List<FeedbackForm> Forms);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle complet d'un document publié (révision 1 au moins). Les erreurs, en français, désignent le type et le
|
||||
/// champ concernés ; elles sont ajoutées à <paramref name="errors"/>.
|
||||
/// </summary>
|
||||
public static bool Validate(FeedbackFormsDocument? document, List<string> errors)
|
||||
{
|
||||
var count = errors.Count;
|
||||
if (document is null)
|
||||
{
|
||||
errors.Add("Formulaires absents.");
|
||||
return false;
|
||||
}
|
||||
if (document.Format != FeedbackFormsDocument.FormatName || document.Version != FeedbackFormsDocument.CurrentVersion)
|
||||
errors.Add($"Format de formulaires non pris en charge ({document.Format} v{document.Version}).");
|
||||
if (document.Revision < 1) errors.Add("Révision des formulaires invalide (1 au moins).");
|
||||
|
||||
var forms = document.Forms ?? new List<FeedbackForm>();
|
||||
if (forms.Count is 0 or > MaxForms || forms.Any(f => f is null))
|
||||
{
|
||||
errors.Add($"Les formulaires doivent comprendre de 1 à {MaxForms} types de demande.");
|
||||
return false;
|
||||
}
|
||||
var kinds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var form in forms)
|
||||
{
|
||||
if (!IsValidId(form.Kind)) errors.Add($"Type « {form.Label} » : identifiant invalide (minuscules, chiffres, « _ »).");
|
||||
else if (!kinds.Add(form.Kind)) errors.Add($"Type « {form.Label} » : identifiant « {form.Kind} » en double.");
|
||||
}
|
||||
if (forms.All(f => f.Hidden)) errors.Add("Au moins un type de demande doit rester proposé aux joueurs.");
|
||||
|
||||
foreach (var form in forms) ValidateForm(form, errors);
|
||||
if (errors.Count == count && Encoding.UTF8.GetByteCount(Serialize(document)) > MaxBytes)
|
||||
errors.Add($"Formulaires trop volumineux (au plus {MaxBytes / 1024} Ko).");
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
private static void ValidateForm(FeedbackForm form, List<string> errors)
|
||||
{
|
||||
var where = $"Type « {form.Label} »";
|
||||
CheckLine(form.Label, MaxFormLabelLength, required: true, $"{where} : nom", errors);
|
||||
CheckLine(form.Description, MaxDescriptionLength, required: true, $"{where} : description", errors);
|
||||
CheckLine(form.TitleHint, MaxTitleHintLength, required: false, $"{where} : exemple de titre", errors);
|
||||
if (!Icons.Contains(form.IconKey, StringComparer.Ordinal)) errors.Add($"{where} : icône inconnue.");
|
||||
if (form.CustomIcon is not null && FeedbackIcons.CheckCustom(form.CustomIcon) is { } iconError) errors.Add($"{where} : {iconError}");
|
||||
|
||||
var fields = form.Fields ?? Array.Empty<FeedbackField>();
|
||||
if (fields.Count > MaxFields) errors.Add($"{where} : {MaxFields} champs au plus.");
|
||||
if (fields.Any(f => f is null))
|
||||
{
|
||||
errors.Add($"{where} : champ vide.");
|
||||
return;
|
||||
}
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var label = $"{where}, champ « {field.Label} »";
|
||||
if (!IsValidId(field.Id)) errors.Add($"{label} : identifiant invalide (minuscules, chiffres, « _ », 32 caractères au plus).");
|
||||
else if (!ids.Add(field.Id)) errors.Add($"{label} : identifiant « {field.Id} » en double.");
|
||||
CheckLine(field.Label, MaxFieldLabelLength, required: true, $"{label} : libellé", errors);
|
||||
CheckHint(field.Hint, $"{label} : aide", errors);
|
||||
if (!Enum.IsDefined(field.Kind)) errors.Add($"{label} : type de champ inconnu.");
|
||||
|
||||
var minLength = field.Kind == FeedbackFieldKind.Multiline ? MinMultilineLength : 1;
|
||||
var maxLength = field.Kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => FeedbackRules.MaxMultilineLength,
|
||||
FeedbackFieldKind.Text => MaxTextLength,
|
||||
_ => FeedbackRules.MaxMultilineLength
|
||||
};
|
||||
if (field.MaxLength < minLength || field.MaxLength > maxLength)
|
||||
errors.Add($"{label} : longueur maximale de {minLength} à {maxLength} caractères.");
|
||||
|
||||
var options = field.Options ?? Array.Empty<FeedbackOption>();
|
||||
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes)
|
||||
{
|
||||
if (options.Count < MinOptions || options.Count > MaxOptions)
|
||||
errors.Add($"{label} : {MinOptions} à {MaxOptions} choix.");
|
||||
var optionIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var option in options)
|
||||
{
|
||||
if (option is null || !IsValidId(option.Id) || !optionIds.Add(option.Id))
|
||||
{
|
||||
errors.Add($"{label} : identifiant de choix invalide ou en double.");
|
||||
continue;
|
||||
}
|
||||
CheckLine(option.Label, MaxOptionLabelLength, required: true, $"{label} : choix « {option.Label} »", errors);
|
||||
}
|
||||
}
|
||||
else if (options.Count > 0)
|
||||
{
|
||||
errors.Add($"{label} : seules les listes et les cases à cocher ont des choix.");
|
||||
}
|
||||
|
||||
if (field.Kind == FeedbackFieldKind.Rating && !RatingSizes.Contains(field.RatingMax))
|
||||
errors.Add($"{label} : nombre d'étoiles invalide ({string.Join(", ", RatingSizes)}).");
|
||||
if (field.ShowInList && field.Kind is not (FeedbackFieldKind.Choice or FeedbackFieldKind.Rating or FeedbackFieldKind.YesNo))
|
||||
errors.Add($"{label} : seules une liste, une note ou une case oui/non peuvent être affichées en aperçu.");
|
||||
}
|
||||
if (fields.Count(f => f.ShowInList) > 1) errors.Add($"{where} : un seul champ peut être affiché en aperçu dans la liste.");
|
||||
}
|
||||
|
||||
/// <summary>Texte d'une ligne déjà propre (tel qu'il sera affiché), sans lien.</summary>
|
||||
private static void CheckLine(string? value, int maxLength, bool required, string what, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
if (required) errors.Add($"{what} : obligatoire.");
|
||||
return;
|
||||
}
|
||||
if (ApiText.CleanLine(value, maxLength) != value)
|
||||
errors.Add($"{what} : {maxLength} caractères au plus, sur une ligne, sans espace en trop ni caractère spécial invisible.");
|
||||
else if (FeedbackLinks.Contains(value))
|
||||
errors.Add($"{what} : les liens et adresses ne sont pas autorisés.");
|
||||
}
|
||||
|
||||
/// <summary>Aide d'un champ : quelques lignes déjà propres, sans lien.</summary>
|
||||
private static void CheckHint(string? value, string what, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return;
|
||||
var clean = FeedbackRules.CleanMultiline(value, MaxHintLength, out var problem);
|
||||
if (problem is not null || clean != value || value.Count(c => c == '\n') + 1 > MaxHintLines)
|
||||
errors.Add($"{what} : {MaxHintLength} caractères et {MaxHintLines} lignes au plus, sans caractère spécial invisible.");
|
||||
else if (FeedbackLinks.Contains(value))
|
||||
errors.Add($"{what} : les liens et adresses ne sont pas autorisés.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mots qui laissent penser qu'un champ demande des données personnelles ou secrètes : AVERTISSEMENT seulement
|
||||
/// dans le menu (pas un refus). Les demandes sont anonymes : ne rien demander qui identifie le joueur.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> PersonalDataWords { get; } = new[]
|
||||
{
|
||||
"mot de passe", "password", "e-mail", "email", "courriel", "adresse", "téléphone", "telephone", "nom complet",
|
||||
"date de naissance", "carte", "iban", "identifiant de connexion", "pseudo steam", "compte"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace GameLauncher.Contracts.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Icônes des types de demande : l'une des quatre icônes d'origine, ou une icône importée depuis un fichier .ico
|
||||
/// (OBLIGATOIREMENT) contenant une image de 64 × 64 pixels.
|
||||
/// • L'import (menu d'administration) lit le fichier .ico sans bibliothèque : en-tête et répertoire contrôlés, image
|
||||
/// 64 × 64 au format PNG (revérifiée) ou bitmap 32 ou 24 bits (avec son masque de transparence) ; tout le reste est
|
||||
/// refusé. L'image est réécrite en PNG 64 × 64 neuf, sans aucune métadonnée.
|
||||
/// • Le PNG obtenu est intégré (base64) aux formulaires SIGNÉS : le serveur et les launchers le revérifient
|
||||
/// (<see cref="CheckCustom"/> : PNG valide, exactement 64 × 64, 20 Ko au plus, déjà nettoyé).
|
||||
/// </summary>
|
||||
public static class FeedbackIcons
|
||||
{
|
||||
/// <summary>Côté de l'image attendue dans le fichier .ico et de l'icône publiée.</summary>
|
||||
public const int Size = 64;
|
||||
|
||||
/// <summary>Taille d'un fichier .ico accepté à l'import.</summary>
|
||||
public const int MaxIcoBytes = 1024 * 1024;
|
||||
|
||||
/// <summary>Taille de l'icône publiée (PNG).</summary>
|
||||
public const int MaxPngBytes = 20 * 1024;
|
||||
|
||||
/// <summary>Les quatre icônes d'origine des types de demande (ressources « Icon.<clé> » du launcher).</summary>
|
||||
public static readonly IReadOnlyList<string> DefaultIcons = new[] { "Star", "Lightbulb", "Bug", "Forum" };
|
||||
|
||||
public static string DefaultIconLabel(string? key) => key switch
|
||||
{
|
||||
"Star" => "Étoile",
|
||||
"Lightbulb" => "Ampoule",
|
||||
"Bug" => "Insecte (bug)",
|
||||
"Forum" => "Bulles de discussion",
|
||||
_ => key ?? ""
|
||||
};
|
||||
|
||||
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||
|
||||
/// <summary>
|
||||
/// Convertit un fichier .ico en icône publiable (PNG 64 × 64 sans métadonnées). Renvoie null si tout va bien, sinon
|
||||
/// la raison du refus (française, lisible).
|
||||
/// </summary>
|
||||
public static string? FromIco(ReadOnlySpan<byte> ico, out byte[]? png)
|
||||
{
|
||||
png = null;
|
||||
if (ico.Length > MaxIcoBytes) return $"Fichier trop volumineux (au plus {MaxIcoBytes / 1024} Ko).";
|
||||
// ICONDIR : réservé = 0, type = 1 (icône), nombre d'images.
|
||||
if (ico.Length < 6 || BinaryPrimitives.ReadUInt16LittleEndian(ico) != 0 || BinaryPrimitives.ReadUInt16LittleEndian(ico[2..]) != 1)
|
||||
return "Ce fichier n'est pas une icône .ico.";
|
||||
var count = BinaryPrimitives.ReadUInt16LittleEndian(ico[4..]);
|
||||
if (count is 0 or > 64 || ico.Length < 6 + count * 16) return "Icône .ico endommagée (répertoire des images).";
|
||||
|
||||
var sizes = new List<string>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var entry = ico.Slice(6 + i * 16, 16);
|
||||
var width = entry[0] == 0 ? 256 : entry[0];
|
||||
var height = entry[1] == 0 ? 256 : entry[1];
|
||||
sizes.Add($"{width} × {height}");
|
||||
if (width != Size || height != Size) continue;
|
||||
|
||||
var length = BinaryPrimitives.ReadUInt32LittleEndian(entry[8..]);
|
||||
var offset = BinaryPrimitives.ReadUInt32LittleEndian(entry[12..]);
|
||||
if (length == 0 || offset < 6 + (uint)count * 16 || (ulong)offset + length > (ulong)ico.Length)
|
||||
return "Icône .ico endommagée (image 64 × 64 hors du fichier).";
|
||||
var image = ico.Slice((int)offset, (int)length);
|
||||
return image.StartsWith(PngSignature) ? FromPng(image, out png) : FromBitmap(image, out png);
|
||||
}
|
||||
return $"Le fichier .ico doit contenir une image de {Size} × {Size} pixels (images trouvées : {string.Join(", ", sizes.Distinct())}).";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Icône publiée (base64) contrôlée : PNG valide, exactement 64 × 64, 20 Ko au plus, déjà nettoyé (aucune
|
||||
/// métadonnée). Null si elle est acceptable, sinon la raison du refus.
|
||||
/// </summary>
|
||||
public static string? CheckCustom(string? base64)
|
||||
{
|
||||
var bytes = Decode(base64);
|
||||
if (bytes is null) return "Icône importée illisible.";
|
||||
if (bytes.Length > MaxPngBytes) return $"Icône importée trop volumineuse (au plus {MaxPngBytes / 1024} Ko).";
|
||||
if (FeedbackImages.Sanitize(bytes, out var info, out var clean) is { } error) return $"Icône importée refusée : {error}";
|
||||
if (info!.Format != FeedbackImages.Png || info.Width != Size || info.Height != Size)
|
||||
return $"L'icône importée doit être une image PNG de {Size} × {Size} pixels.";
|
||||
if (!clean!.AsSpan().SequenceEqual(bytes)) return "Icône importée non nettoyée (métadonnées).";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Octets d'une icône publiée (null : absente ou base64 invalide).</summary>
|
||||
public static byte[]? Decode(string? base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64) || base64.Length > MaxPngBytes * 2) return null;
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Images de l'icône
|
||||
|
||||
/// <summary>Image PNG de l'icône : revérifiée et nettoyée (métadonnées retirées), 64 × 64 exigé.</summary>
|
||||
private static string? FromPng(ReadOnlySpan<byte> image, out byte[]? png)
|
||||
{
|
||||
png = null;
|
||||
if (FeedbackImages.Sanitize(image, out var info, out var clean) is { } error) return $"Image 64 × 64 de l'icône refusée : {error}";
|
||||
if (info!.Width != Size || info.Height != Size) return $"L'image de l'icône doit faire {Size} × {Size} pixels.";
|
||||
if (clean!.Length > MaxPngBytes) return $"Image de l'icône trop volumineuse (au plus {MaxPngBytes / 1024} Ko une fois nettoyée).";
|
||||
png = clean;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Image bitmap de l'icône (BITMAPINFOHEADER, sans compression, 32 ou 24 bits) : pixels lus ligne par ligne (de bas
|
||||
/// en haut), transparence prise du canal alpha (32 bits) ou du masque 1 bit, puis réécrits en PNG neuf.
|
||||
/// </summary>
|
||||
private static string? FromBitmap(ReadOnlySpan<byte> image, out byte[]? png)
|
||||
{
|
||||
png = null;
|
||||
if (image.Length < 40 || BinaryPrimitives.ReadUInt32LittleEndian(image) != 40)
|
||||
return "Image de l'icône dans un format non pris en charge (PNG ou bitmap 24/32 bits seulement).";
|
||||
var width = BinaryPrimitives.ReadInt32LittleEndian(image[4..]);
|
||||
var height = BinaryPrimitives.ReadInt32LittleEndian(image[8..]);
|
||||
var planes = BinaryPrimitives.ReadUInt16LittleEndian(image[12..]);
|
||||
var bits = BinaryPrimitives.ReadUInt16LittleEndian(image[14..]);
|
||||
var compression = BinaryPrimitives.ReadUInt32LittleEndian(image[16..]);
|
||||
if (width != Size || height != Size * 2 || planes != 1)
|
||||
return $"L'image de l'icône doit faire {Size} × {Size} pixels.";
|
||||
if (compression != 0 || bits is not (32 or 24))
|
||||
return "Image de l'icône en 256 couleurs ou moins : enregistrez l'icône en couleurs 24 ou 32 bits (ou PNG).";
|
||||
|
||||
var stride = (Size * bits / 8 + 3) & ~3;
|
||||
var maskStride = (Size + 31) / 32 * 4;
|
||||
var pixels = image[40..];
|
||||
if (pixels.Length < stride * Size) return "Icône .ico endommagée (image incomplète).";
|
||||
var mask = pixels.Length >= stride * Size + maskStride * Size ? pixels.Slice(stride * Size, maskStride * Size) : ReadOnlySpan<byte>.Empty;
|
||||
|
||||
var rgba = new byte[Size * Size * 4];
|
||||
var hasAlpha = false;
|
||||
for (var y = 0; y < Size; y++)
|
||||
{
|
||||
var row = pixels.Slice((Size - 1 - y) * stride, stride); // lignes de bas en haut
|
||||
for (var x = 0; x < Size; x++)
|
||||
{
|
||||
var source = x * (bits / 8);
|
||||
var target = (y * Size + x) * 4;
|
||||
rgba[target] = row[source + 2];
|
||||
rgba[target + 1] = row[source + 1];
|
||||
rgba[target + 2] = row[source];
|
||||
rgba[target + 3] = bits == 32 ? row[source + 3] : (byte)255;
|
||||
if (bits == 32 && row[source + 3] != 0) hasAlpha = true;
|
||||
}
|
||||
}
|
||||
// Pas de canal alpha utilisable : transparence donnée par le masque (bit à 1 = transparent).
|
||||
if (!hasAlpha && !mask.IsEmpty)
|
||||
{
|
||||
for (var y = 0; y < Size; y++)
|
||||
{
|
||||
var row = mask.Slice((Size - 1 - y) * maskStride, maskStride);
|
||||
for (var x = 0; x < Size; x++)
|
||||
rgba[(y * Size + x) * 4 + 3] = (row[x / 8] & (0x80 >> (x % 8))) != 0 ? (byte)0 : (byte)255;
|
||||
}
|
||||
}
|
||||
else if (!hasAlpha)
|
||||
{
|
||||
for (var i = 3; i < rgba.Length; i += 4) rgba[i] = 255;
|
||||
}
|
||||
|
||||
var encoded = EncodePng(rgba);
|
||||
// Contrôle final avec les règles des images (comme toute icône publiée).
|
||||
if (FeedbackImages.Sanitize(encoded, out _, out var clean) is { } error) return $"Conversion de l'icône impossible : {error}";
|
||||
if (clean!.Length > MaxPngBytes) return $"Icône trop détaillée (au plus {MaxPngBytes / 1024} Ko une fois convertie en PNG).";
|
||||
png = clean;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>PNG 64 × 64 RVBA (8 bits par canal, sans entrelacement, filtre « Sub »), sans aucun bloc facultatif.</summary>
|
||||
private static byte[] EncodePng(byte[] rgba)
|
||||
{
|
||||
var raw = new byte[Size * (1 + Size * 4)];
|
||||
for (var y = 0; y < Size; y++)
|
||||
{
|
||||
var rowStart = y * (1 + Size * 4);
|
||||
raw[rowStart] = 1; // Sub : meilleure compression des aplats
|
||||
for (var i = 0; i < Size * 4; i++)
|
||||
{
|
||||
var value = rgba[y * Size * 4 + i];
|
||||
var left = i >= 4 ? rgba[y * Size * 4 + i - 4] : (byte)0;
|
||||
raw[rowStart + 1 + i] = unchecked((byte)(value - left));
|
||||
}
|
||||
}
|
||||
byte[] compressed;
|
||||
using (var buffer = new MemoryStream())
|
||||
{
|
||||
using (var zlib = new ZLibStream(buffer, CompressionLevel.SmallestSize, leaveOpen: true)) zlib.Write(raw);
|
||||
compressed = buffer.ToArray();
|
||||
}
|
||||
|
||||
using var png = new MemoryStream();
|
||||
png.Write(PngSignature);
|
||||
var header = new byte[13];
|
||||
BinaryPrimitives.WriteInt32BigEndian(header, Size);
|
||||
BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), Size);
|
||||
header[8] = 8; // bits par canal
|
||||
header[9] = 6; // RVBA
|
||||
WriteChunk(png, "IHDR", header);
|
||||
WriteChunk(png, "IDAT", compressed);
|
||||
WriteChunk(png, "IEND", Array.Empty<byte>());
|
||||
return png.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteChunk(Stream stream, string type, byte[] body)
|
||||
{
|
||||
Span<byte> number = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(number, body.Length);
|
||||
stream.Write(number);
|
||||
var typeBytes = System.Text.Encoding.ASCII.GetBytes(type);
|
||||
stream.Write(typeBytes);
|
||||
stream.Write(body);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(number, Crc32(typeBytes, body));
|
||||
stream.Write(number);
|
||||
}
|
||||
|
||||
private static readonly uint[] CrcTable = Enumerable.Range(0, 256).Select(n =>
|
||||
{
|
||||
var c = (uint)n;
|
||||
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
|
||||
return c;
|
||||
}).ToArray();
|
||||
|
||||
private static uint Crc32(ReadOnlySpan<byte> type, ReadOnlySpan<byte> body)
|
||||
{
|
||||
var c = 0xFFFFFFFFu;
|
||||
foreach (var b in type) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||
foreach (var b in body) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
|
||||
return c ^ 0xFFFFFFFFu;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ public static partial class FeedbackRules
|
||||
|
||||
public const int MaxAdminNoteLength = 500;
|
||||
|
||||
/// <summary>Valeurs d'une case oui/non.</summary>
|
||||
public const string Yes = "yes";
|
||||
public const string No = "no";
|
||||
|
||||
/// <summary>Identifiant interne d'une demande : 128 bits en hexadécimal minuscule.</summary>
|
||||
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
|
||||
private static partial Regex IdRegex();
|
||||
@@ -64,11 +68,16 @@ public static partial class FeedbackRules
|
||||
return $"FB-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
|
||||
}
|
||||
|
||||
/// <summary>Contrôle complet d'une demande avec les formulaires d'origine (révision 0).</summary>
|
||||
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, ICollection<string> errors) =>
|
||||
Validate(submission, FeedbackForms.Find(submission?.Kind), errors);
|
||||
|
||||
/// <summary>
|
||||
/// Contrôle complet d'une demande. Renvoie la demande nettoyée (textes normalisés, champs vides retirés,
|
||||
/// défi conservé) ou null avec les erreurs (françaises, lisibles par le joueur).
|
||||
/// Contrôle complet d'une demande avec le formulaire de son type (celui de la révision annoncée). Renvoie la
|
||||
/// demande nettoyée (textes normalisés, champs vides retirés, défi conservé) ou null avec les erreurs
|
||||
/// (françaises, lisibles par le joueur).
|
||||
/// </summary>
|
||||
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, ICollection<string> errors)
|
||||
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, FeedbackForm? form, ICollection<string> errors)
|
||||
{
|
||||
if (submission is null)
|
||||
{
|
||||
@@ -78,12 +87,14 @@ public static partial class FeedbackRules
|
||||
if (!Catalog.CatalogRules.IsValidGameId(submission.GameId)) errors.Add("Jeu inconnu.");
|
||||
if (submission.FormVersion != FeedbackForms.CurrentVersion)
|
||||
errors.Add("Formulaire d'une autre version du launcher : mettez le launcher à jour.");
|
||||
var form = FeedbackForms.Find(submission.Kind);
|
||||
if (form is null)
|
||||
if (submission.FormRevision < 0) errors.Add("Révision des formulaires invalide.");
|
||||
if (form is null || !string.Equals(form.Kind, submission.Kind, StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add("Type de demande inconnu.");
|
||||
return null;
|
||||
}
|
||||
if (!form.AllowAttachments && submission.AttachmentCount > 0)
|
||||
errors.Add("Les captures d'écran ne sont pas acceptées pour ce type de demande.");
|
||||
|
||||
var title = ApiText.CleanLine(submission.Title, MaxTitleLength);
|
||||
if (title is null || title.Length < MinTitleLength)
|
||||
@@ -134,7 +145,8 @@ public static partial class FeedbackRules
|
||||
return null;
|
||||
}
|
||||
var valid = field.Kind == FeedbackFieldKind.Rating
|
||||
? raw is "1" or "2" or "3" or "4" or "5"
|
||||
? raw.Length <= 2 && raw.All(char.IsAsciiDigit) && raw[0] != '0' &&
|
||||
int.Parse(raw, CultureInfo.InvariantCulture) <= field.RatingMax
|
||||
: field.Choices.Any(o => string.Equals(o.Id, raw, StringComparison.Ordinal));
|
||||
if (!valid)
|
||||
{
|
||||
@@ -143,6 +155,46 @@ public static partial class FeedbackRules
|
||||
}
|
||||
return raw;
|
||||
|
||||
case FeedbackFieldKind.Checkboxes:
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
{
|
||||
if (field.Required) errors.Add($"{field.Label} : cochez au moins un choix.");
|
||||
return null;
|
||||
}
|
||||
var ids = raw.Split(',');
|
||||
if (ids.Length > field.Choices.Count || ids.Distinct(StringComparer.Ordinal).Count() != ids.Length ||
|
||||
ids.Any(id => !field.Choices.Any(o => string.Equals(o.Id, id, StringComparison.Ordinal))))
|
||||
{
|
||||
errors.Add($"{field.Label} : valeur invalide.");
|
||||
return null;
|
||||
}
|
||||
// Ordre des options du formulaire : une même sélection donne toujours la même valeur.
|
||||
return string.Join(',', field.Choices.Select(o => o.Id).Where(id => ids.Contains(id, StringComparer.Ordinal)));
|
||||
|
||||
case FeedbackFieldKind.YesNo:
|
||||
if (string.IsNullOrEmpty(raw)) raw = No;
|
||||
if (raw is not (Yes or No))
|
||||
{
|
||||
errors.Add($"{field.Label} : valeur invalide.");
|
||||
return null;
|
||||
}
|
||||
if (field.Required && raw != Yes) errors.Add($"{field.Label} : case à cocher obligatoire.");
|
||||
return raw;
|
||||
|
||||
case FeedbackFieldKind.Text:
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
if (field.Required) errors.Add($"{field.Label} : champ obligatoire.");
|
||||
return null;
|
||||
}
|
||||
var line = ApiText.CleanLine(raw, field.MaxLength);
|
||||
if (line is null)
|
||||
{
|
||||
errors.Add($"{field.Label} : {field.MaxLength} caractères au plus, sur une ligne, sans caractère spécial invisible.");
|
||||
return null;
|
||||
}
|
||||
return line;
|
||||
|
||||
default:
|
||||
var text = CleanMultiline(raw, field.MaxLength, out var problem);
|
||||
if (problem is not null)
|
||||
|
||||
@@ -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] + "…";
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,16 @@ public static class AdminPermissions
|
||||
/// <summary>Traitement des demandes des joueurs : statut, note interne, suppression.</summary>
|
||||
public const string FeedbackManage = "feedback.manage";
|
||||
|
||||
/// <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
|
||||
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms,
|
||||
MaintenanceManage
|
||||
};
|
||||
|
||||
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
|
||||
@@ -53,6 +60,8 @@ public static class AdminPermissions
|
||||
FilesManage => "Fichiers du serveur",
|
||||
FeedbackRead => "Demandes des joueurs (lecture)",
|
||||
FeedbackManage => "Demandes des joueurs (traitement)",
|
||||
FeedbackForms => "Formulaires des demandes",
|
||||
MaintenanceManage => "Mode maintenance",
|
||||
_ => null
|
||||
};
|
||||
|
||||
@@ -70,6 +79,11 @@ public static class AdminPermissions
|
||||
"des données personnelles. Chaque consultation est inscrite au journal d'audit.",
|
||||
FeedbackManage => "Changer le statut des demandes des joueurs, écrire une note interne, supprimer une demande " +
|
||||
"(à 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.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms,
|
||||
AdminPermissions.MaintenanceManage
|
||||
},
|
||||
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
|
||||
};
|
||||
|
||||
@@ -28,12 +28,26 @@ 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";
|
||||
|
||||
/// <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 == Manifest ? SigningScopes.Releases : SigningScopes.Catalog;
|
||||
public static string ScopeOf(string kind) => kind switch
|
||||
{
|
||||
Manifest => SigningScopes.Releases,
|
||||
FeedbackForms => SigningScopes.Forms,
|
||||
Maintenance => SigningScopes.Maintenance,
|
||||
_ => SigningScopes.Catalog
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Autorisations d'une clé du trousseau.</summary>
|
||||
@@ -45,7 +59,20 @@ public static class SigningScopes
|
||||
/// <summary>Signer le catalogue (noms, liens, images affichés) : permission games.edit.</summary>
|
||||
public const string Catalog = "catalog";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog };
|
||||
/// <summary>Signer les formulaires des demandes (champs affichés aux joueurs) : permission feedback.forms.</summary>
|
||||
public const string Forms = "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
|
||||
/// acceptée dans le trousseau mais n'autorise rien ici (<see cref="ContentSigning.Verify"/> exige l'autorisation exacte).
|
||||
/// </summary>
|
||||
public static bool IsWellFormed(string? scope) =>
|
||||
scope is { Length: >= 1 and <= 32 } && scope[0] is >= 'a' and <= 'z' && scope.All(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-');
|
||||
|
||||
/// <summary>Autorisations qu'apportent les permissions d'un administrateur.</summary>
|
||||
public static IReadOnlyList<string> FromPermissions(IEnumerable<string> permissions)
|
||||
@@ -54,6 +81,8 @@ public static class SigningScopes
|
||||
var scopes = new List<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -61,6 +90,8 @@ public static class SigningScopes
|
||||
{
|
||||
Releases => "versions du jeu",
|
||||
Catalog => "catalogue",
|
||||
Forms => "formulaires des demandes",
|
||||
Maintenance => "mode maintenance",
|
||||
_ => scope
|
||||
};
|
||||
}
|
||||
@@ -194,7 +225,13 @@ public static class ContentSigning
|
||||
private sealed record CanonicalCatalog(string Format, int Version, CatalogLauncher? Launcher, SortedDictionary<string, CatalogGame> Games);
|
||||
|
||||
/// <summary>Identifiant signé : jeu en minuscules, ou « catalog ».</summary>
|
||||
public static string SignedId(string kind, string id) => kind == ContentKinds.Catalog ? ContentKinds.CatalogId : id.ToLowerInvariant();
|
||||
public static string SignedId(string kind, string id) => kind switch
|
||||
{
|
||||
ContentKinds.Catalog => ContentKinds.CatalogId,
|
||||
ContentKinds.FeedbackForms => ContentKinds.FeedbackFormsId,
|
||||
ContentKinds.Maintenance => ContentKinds.MaintenanceId,
|
||||
_ => id.ToLowerInvariant()
|
||||
};
|
||||
|
||||
/// <summary>Date de signature ramenée à la seconde (UTC), comme dans le message signé.</summary>
|
||||
public static DateTimeOffset Truncate(DateTimeOffset value) => DateTimeOffset.FromUnixTimeSeconds(value.ToUnixTimeSeconds());
|
||||
|
||||
@@ -127,8 +127,9 @@ public static class Keyring
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(key.Name) || key.Name.Length > MaxNameLength || !IsDisplaySafe(key.Name))
|
||||
errors.Add($"Clé {label} : nom absent, trop long ou invalide.");
|
||||
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.All.Contains(s, StringComparer.Ordinal)))
|
||||
errors.Add($"Clé {label} : autorisations absentes ou inconnues.");
|
||||
// Autorisation inconnue mais bien formée (ajoutée par une version plus récente) : acceptée, sans effet ici.
|
||||
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.IsWellFormed(s)))
|
||||
errors.Add($"Clé {label} : autorisations absentes ou invalides.");
|
||||
}
|
||||
return errors.Count == count;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
@@ -39,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;
|
||||
@@ -80,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)
|
||||
@@ -93,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();
|
||||
@@ -149,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;
|
||||
}
|
||||
@@ -190,6 +211,57 @@ public sealed class ContentTrustService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie les formulaires des demandes téléchargés (feedback-forms.json) : clé du trousseau autorisée « forms »,
|
||||
/// signature valide, pas plus anciens que les derniers acceptés. Faux : à ignorer (formulaires précédents).
|
||||
/// </summary>
|
||||
public async Task<bool> VerifyFeedbackFormsAsync(FeedbackFormsDocument forms, CancellationToken ct = default)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
try
|
||||
{
|
||||
await VerifyAsync(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(forms), forms.Signature,
|
||||
$"Formulaires des demandes (révision {forms.Revision})", ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (ContentSecurityException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (Mode == ContentSigningMode.Off) return true;
|
||||
var what = $"Formulaires des demandes en cache (révision {forms.Revision})";
|
||||
var keyring = LoadState().Keyring;
|
||||
if (keyring is null) return Report(what, "aucun trousseau vérifié en cache");
|
||||
var check = ContentSigning.Verify(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(forms),
|
||||
forms.Signature, keyring);
|
||||
return check.Valid || Report(what, check.Reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revérifie le catalogue en cache au démarrage, sans réseau, avec le dernier trousseau vérifié : en mode
|
||||
/// « require », un catalogue en cache non signé (écrit avant l'activation, ou modifié sur le disque) est ignoré.
|
||||
@@ -221,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;
|
||||
@@ -269,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é")
|
||||
};
|
||||
@@ -503,6 +504,10 @@ public static class AuditTexts
|
||||
"feedback.update" => "Demande de joueur traitée (statut ou note)",
|
||||
"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",
|
||||
|
||||
@@ -4,8 +4,9 @@ namespace GameLauncher.Modules.Admin.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Module « Demandes des joueurs » : page de lecture et de traitement des demandes envoyées depuis le bouton « Aide »
|
||||
/// (permission feedback.read ; statut, note et suppression : feedback.manage). Le formulaire des joueurs est dans
|
||||
/// GameLauncher.Modules.Feedback.
|
||||
/// (permission feedback.read ; statut, note et suppression : feedback.manage), et page « Formulaires des demandes »
|
||||
/// (feedback.forms : types de demande et champs proposés aux joueurs, publication signée). Le formulaire des joueurs
|
||||
/// est dans GameLauncher.Modules.Feedback.
|
||||
/// </summary>
|
||||
public sealed class FeedbackAdminModule : ILauncherModule
|
||||
{
|
||||
@@ -13,7 +14,8 @@ public sealed class FeedbackAdminModule : ILauncherModule
|
||||
|
||||
public string DisplayName => "Demandes des joueurs";
|
||||
|
||||
public void Register(IModuleRegistry registry) =>
|
||||
public void Register(IModuleRegistry registry)
|
||||
{
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
FeedbackPanelViewModel.PanelId,
|
||||
"Demandes des joueurs",
|
||||
@@ -22,4 +24,13 @@ public sealed class FeedbackAdminModule : ILauncherModule
|
||||
AdminPermissions.FeedbackRead,
|
||||
Order: 15,
|
||||
context => new FeedbackPanelViewModel(context)));
|
||||
registry.AddAdminPanel(new AdminPanelDescriptor(
|
||||
FeedbackFormsPanelViewModel.PanelId,
|
||||
"Formulaires des demandes",
|
||||
"Les quatre types de demande du bouton « Aide » et leurs champs, communs à tous les jeux : modifier, masquer, publier (signé).",
|
||||
"TextBoxOutline",
|
||||
AdminPermissions.FeedbackForms,
|
||||
Order: 16,
|
||||
context => new FeedbackFormsPanelViewModel(context)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Modules.Admin.Catalog;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Feedback;
|
||||
|
||||
/// <summary>Type de champ proposé par « Ajouter un champ ».</summary>
|
||||
public sealed class FeedbackFieldKindChoice
|
||||
{
|
||||
public FeedbackFieldKindChoice(FeedbackFieldKind kind, Action<FeedbackFieldKind> add)
|
||||
{
|
||||
Kind = kind;
|
||||
Label = FeedbackFieldEditor.KindLabel(kind);
|
||||
IconKey = FeedbackFieldEditor.KindIcon(kind);
|
||||
AddCommand = new RelayCommand(() => add(kind));
|
||||
}
|
||||
|
||||
public FeedbackFieldKind Kind { get; }
|
||||
public string Label { get; }
|
||||
public string IconKey { get; }
|
||||
public RelayCommand AddCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>Choix d'une liste déroulante ou d'une case à cocher : identifiant fixe (clé des valeurs envoyées), libellé modifiable.</summary>
|
||||
public sealed class FeedbackOptionEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private string _label;
|
||||
|
||||
public FeedbackOptionEditor(string id, string label, Action changed, Action<FeedbackOptionEditor, int> move, Action<FeedbackOptionEditor> remove)
|
||||
{
|
||||
Id = id;
|
||||
_label = label;
|
||||
_changed = changed;
|
||||
MoveUpCommand = new RelayCommand(() => move(this, -1));
|
||||
MoveDownCommand = new RelayCommand(() => move(this, 1));
|
||||
RemoveCommand = new RelayCommand(() => remove(this));
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Label
|
||||
{
|
||||
get => _label;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _label, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
|
||||
public FeedbackOption Build() => new(Id, ApiText.CleanLine(_label, FeedbackFormRules.MaxOptionLabelLength) ?? _label);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Champ d'un formulaire en cours de modification. Son identifiant (clé des valeurs envoyées par les joueurs) et son
|
||||
/// type sont fixés à la création : changer de type revient à retirer le champ et à en ajouter un autre, pour que les
|
||||
/// demandes déjà reçues restent lisibles.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFieldEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private string _label;
|
||||
private string _hint;
|
||||
private bool _required;
|
||||
private int _maxLength;
|
||||
private Choice _ratingMax;
|
||||
private bool _showInList;
|
||||
private bool _isExpanded;
|
||||
private int _optionCounter;
|
||||
|
||||
public FeedbackFieldEditor(FeedbackField field, bool isNew, Action changed, Action<FeedbackFieldEditor, int> move,
|
||||
Action<FeedbackFieldEditor> remove)
|
||||
{
|
||||
Id = field.Id;
|
||||
Kind = field.Kind;
|
||||
IsNew = isNew;
|
||||
_changed = changed;
|
||||
_label = field.Label;
|
||||
_hint = field.Hint ?? "";
|
||||
_required = field.Required;
|
||||
_maxLength = field.MaxLength;
|
||||
RatingSizes = FeedbackFormRules.RatingSizes.Select(n => new Choice(n.ToString(System.Globalization.CultureInfo.InvariantCulture), $"{n} étoiles")).ToList();
|
||||
_ratingMax = RatingSizes.FirstOrDefault(c => c.Value == field.RatingMax.ToString(System.Globalization.CultureInfo.InvariantCulture)) ?? RatingSizes[1];
|
||||
_showInList = field.ShowInList;
|
||||
_isExpanded = isNew;
|
||||
foreach (var option in field.Choices) Options.Add(NewOption(option.Id, option.Label));
|
||||
_optionCounter = field.Choices.Count;
|
||||
Options.CollectionChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
_changed();
|
||||
};
|
||||
|
||||
MoveUpCommand = new RelayCommand(() => move(this, -1));
|
||||
MoveDownCommand = new RelayCommand(() => move(this, 1));
|
||||
RemoveCommand = new RelayCommand(() => remove(this));
|
||||
ToggleCommand = new RelayCommand(() => IsExpanded = !IsExpanded);
|
||||
AddOptionCommand = new RelayCommand(AddOption, () => Options.Count < FeedbackFormRules.MaxOptions);
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
public FeedbackFieldKind Kind { get; }
|
||||
|
||||
/// <summary>Champ ajouté depuis la dernière publication.</summary>
|
||||
public bool IsNew { get; }
|
||||
|
||||
public string KindText => KindLabel(Kind);
|
||||
public string KindIconKey => KindIcon(Kind);
|
||||
|
||||
public bool HasOptions => Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes;
|
||||
public bool HasMaxLength => Kind is FeedbackFieldKind.Multiline or FeedbackFieldKind.Text;
|
||||
public bool IsRating => Kind == FeedbackFieldKind.Rating;
|
||||
|
||||
/// <summary>Seuls une liste, une note ou une case oui/non peuvent servir d'aperçu dans la liste des demandes.</summary>
|
||||
public bool CanShowInList => Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Rating or FeedbackFieldKind.YesNo;
|
||||
|
||||
public string RequiredText => Kind == FeedbackFieldKind.YesNo ? "Obligatoire (la case doit être cochée)" : "Obligatoire";
|
||||
|
||||
public string MaxLengthText => Kind == FeedbackFieldKind.Text
|
||||
? $"Longueur maximale (1 à {FeedbackFormRules.MaxTextLength})"
|
||||
: $"Longueur maximale ({FeedbackFormRules.MinMultilineLength} à {FeedbackRules.MaxMultilineLength})";
|
||||
|
||||
public string Label
|
||||
{
|
||||
get => _label;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _label, value ?? "")) return;
|
||||
OnPropertyChanged(nameof(Title));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Titre de la ligne repliée (libellé, sinon « Champ sans libellé »).</summary>
|
||||
public string Title => string.IsNullOrWhiteSpace(_label) ? "Champ sans libellé" : _label;
|
||||
|
||||
public string Hint
|
||||
{
|
||||
get => _hint;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _hint, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool Required
|
||||
{
|
||||
get => _required;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _required, value)) return;
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public int MaxLength
|
||||
{
|
||||
get => _maxLength;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _maxLength, value)) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> RatingSizes { get; }
|
||||
|
||||
public Choice RatingMax
|
||||
{
|
||||
get => _ratingMax;
|
||||
set
|
||||
{
|
||||
if (value is null || !SetProperty(ref _ratingMax, value)) return;
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowInList
|
||||
{
|
||||
get => _showInList;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _showInList, value)) return;
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsExpanded
|
||||
{
|
||||
get => _isExpanded;
|
||||
set => SetProperty(ref _isExpanded, value);
|
||||
}
|
||||
|
||||
/// <summary>Résumé de la ligne repliée : type, obligatoire, choix, aperçu.</summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
var parts = new List<string> { KindText };
|
||||
if (_required) parts.Add("obligatoire");
|
||||
if (HasOptions) parts.Add($"{Options.Count} choix");
|
||||
if (IsRating) parts.Add(_ratingMax.Label);
|
||||
if (_showInList && CanShowInList) parts.Add("aperçu dans la liste");
|
||||
if (IsNew) parts.Add("nouveau");
|
||||
return string.Join(" · ", parts);
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<FeedbackOptionEditor> Options { get; } = new();
|
||||
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
public RelayCommand ToggleCommand { get; }
|
||||
public RelayCommand AddOptionCommand { get; }
|
||||
|
||||
/// <summary>Champ tel qu'il sera publié (espaces en trop retirés ; le reste est contrôlé par FeedbackFormRules).</summary>
|
||||
public FeedbackField Build() => new(
|
||||
Id,
|
||||
ApiText.CleanLine(_label, FeedbackFormRules.MaxFieldLabelLength) ?? _label,
|
||||
Kind,
|
||||
_required,
|
||||
string.IsNullOrWhiteSpace(_hint) ? null : FeedbackRules.CleanMultiline(_hint, FeedbackFormRules.MaxHintLength, out _) ?? _hint,
|
||||
HasMaxLength ? _maxLength : FeedbackRules.MaxMultilineLength,
|
||||
HasOptions ? Options.Select(o => o.Build()).ToList() : null,
|
||||
IsRating && int.TryParse(_ratingMax.Value, out var max) ? max : 5,
|
||||
_showInList && CanShowInList);
|
||||
|
||||
private void AddOption()
|
||||
{
|
||||
var id = NextOptionId();
|
||||
Options.Add(NewOption(id, $"Choix {Options.Count + 1}"));
|
||||
AddOptionCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private string NextOptionId()
|
||||
{
|
||||
string id;
|
||||
do id = $"choix_{++_optionCounter}";
|
||||
while (Options.Any(o => o.Id == id));
|
||||
return id;
|
||||
}
|
||||
|
||||
private FeedbackOptionEditor NewOption(string id, string label) => new(id, label, _changed, MoveOption, option =>
|
||||
{
|
||||
Options.Remove(option);
|
||||
AddOptionCommand.RaiseCanExecuteChanged();
|
||||
});
|
||||
|
||||
private void MoveOption(FeedbackOptionEditor option, int delta)
|
||||
{
|
||||
var index = Options.IndexOf(option);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= Options.Count) return;
|
||||
Options.Move(index, target);
|
||||
}
|
||||
|
||||
public static string KindLabel(FeedbackFieldKind kind) => kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => "Zone de texte",
|
||||
FeedbackFieldKind.Text => "Texte court",
|
||||
FeedbackFieldKind.Choice => "Liste déroulante",
|
||||
FeedbackFieldKind.Checkboxes => "Cases à cocher",
|
||||
FeedbackFieldKind.YesNo => "Case oui/non",
|
||||
FeedbackFieldKind.Rating => "Étoiles",
|
||||
_ => kind.ToString()
|
||||
};
|
||||
|
||||
public static string KindIcon(FeedbackFieldKind kind) => kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => "TextBoxOutline",
|
||||
FeedbackFieldKind.Text => "Minus",
|
||||
FeedbackFieldKind.Choice => "ChevronRight",
|
||||
FeedbackFieldKind.Checkboxes => "CheckCircle",
|
||||
FeedbackFieldKind.YesNo => "Check",
|
||||
FeedbackFieldKind.Rating => "Star",
|
||||
_ => "Info"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Actions d'un type de demande qui touchent la liste (ordre, suppression) ou demandent un fichier (icône).</summary>
|
||||
public sealed record FeedbackFormEditorActions(
|
||||
Action<FeedbackFormEditor, int> Move,
|
||||
Action<FeedbackFormEditor> Remove,
|
||||
Action<FeedbackFormEditor> ImportIcon);
|
||||
|
||||
/// <summary>
|
||||
/// Type de demande en cours de modification (ajouté, supprimé, réordonné, masqué) : présentation (nom, description,
|
||||
/// icône d'origine ou importée, captures d'écran autorisées…) et champs.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFormEditor : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private readonly HashSet<string> _publishedIds;
|
||||
private string _label;
|
||||
private string _description;
|
||||
private Choice _icon;
|
||||
private string _titleHint;
|
||||
private bool _techByDefault;
|
||||
private bool _hidden;
|
||||
private bool _allowAttachments;
|
||||
private string? _customIcon;
|
||||
private bool _isDirty;
|
||||
private int _fieldCounter;
|
||||
|
||||
/// <param name="isNew">Type ajouté depuis la dernière publication.</param>
|
||||
public FeedbackFormEditor(FeedbackForm form, Action changed, FeedbackFormEditorActions? actions = null, bool isNew = false)
|
||||
{
|
||||
Kind = form.Kind;
|
||||
IsNew = isNew;
|
||||
_changed = changed;
|
||||
_label = form.Label;
|
||||
_description = form.Description;
|
||||
Icons = FeedbackFormRules.Icons.Select(i => new Choice(i, FeedbackIcons.DefaultIconLabel(i))).ToList();
|
||||
_icon = Icons.FirstOrDefault(i => i.Value == form.IconKey) ?? Icons[^1];
|
||||
_titleHint = form.TitleHint;
|
||||
_techByDefault = form.TechByDefault;
|
||||
_hidden = form.Hidden;
|
||||
_allowAttachments = form.AllowAttachments;
|
||||
_customIcon = string.IsNullOrEmpty(form.CustomIcon) ? null : form.CustomIcon;
|
||||
MoveUpCommand = new RelayCommand(() => actions?.Move(this, -1));
|
||||
MoveDownCommand = new RelayCommand(() => actions?.Move(this, 1));
|
||||
RemoveCommand = new RelayCommand(() => actions?.Remove(this));
|
||||
ImportIconCommand = new RelayCommand(() => actions?.ImportIcon(this));
|
||||
RemoveIconCommand = new RelayCommand(() => SetCustomIcon(null), () => _customIcon is not null);
|
||||
_publishedIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var field in form.Fields) Fields.Add(NewField(field, isNew: false));
|
||||
Fields.CollectionChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(FieldsText));
|
||||
AddFieldsEnabled = Fields.Count < FeedbackFormRules.MaxFields;
|
||||
_changed();
|
||||
};
|
||||
FieldKinds = Enum.GetValues<FeedbackFieldKind>().Select(k => new FeedbackFieldKindChoice(k, AddField)).ToList();
|
||||
}
|
||||
|
||||
public string Kind { get; }
|
||||
|
||||
/// <summary>Type ajouté depuis la dernière publication.</summary>
|
||||
public bool IsNew { get; }
|
||||
|
||||
/// <summary>Repère fixe dans la liste, même renommé : nom d'origine, ou « Type ajouté ».</summary>
|
||||
public string OriginalLabel => FeedbackForms.Find(Kind)?.Label ?? (IsNew ? "Type ajouté" : $"Type « {Kind} »");
|
||||
|
||||
public RelayCommand MoveUpCommand { get; }
|
||||
public RelayCommand MoveDownCommand { get; }
|
||||
public RelayCommand RemoveCommand { get; }
|
||||
public RelayCommand ImportIconCommand { get; }
|
||||
public RelayCommand RemoveIconCommand { get; }
|
||||
|
||||
/// <summary>Icône importée (PNG 64 × 64 en base64, issu d'un fichier .ico) ; null : icône d'origine choisie.</summary>
|
||||
public string? CustomIcon => _customIcon;
|
||||
public byte[]? CustomIconBytes => FeedbackIcons.Decode(_customIcon);
|
||||
public bool HasCustomIcon => _customIcon is not null;
|
||||
|
||||
/// <summary>Icône d'origine utilisée (liste des quatre icônes active).</summary>
|
||||
public bool UsesDefaultIcon => _customIcon is null;
|
||||
|
||||
public void SetCustomIcon(string? base64)
|
||||
{
|
||||
if (_customIcon == base64) return;
|
||||
_customIcon = base64;
|
||||
foreach (var name in new[] { nameof(CustomIcon), nameof(CustomIconBytes), nameof(HasCustomIcon), nameof(UsesDefaultIcon) }) OnPropertyChanged(name);
|
||||
RemoveIconCommand.RaiseCanExecuteChanged();
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// <summary>Captures d'écran proposées aux joueurs pour ce type (3 au plus, contrôlées et nettoyées).</summary>
|
||||
public bool AllowAttachments
|
||||
{
|
||||
get => _allowAttachments;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _allowAttachments, value)) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public string Label
|
||||
{
|
||||
get => _label;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _label, value ?? "")) return;
|
||||
OnPropertyChanged(nameof(Title));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public string Title => string.IsNullOrWhiteSpace(_label) ? OriginalLabel : _label;
|
||||
|
||||
public string Description
|
||||
{
|
||||
get => _description;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _description, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Choice> Icons { get; }
|
||||
|
||||
public Choice Icon
|
||||
{
|
||||
get => _icon;
|
||||
set
|
||||
{
|
||||
if (value is null || !SetProperty(ref _icon, value)) return;
|
||||
OnPropertyChanged(nameof(IconKey));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
public string IconKey => _icon.Value ?? "Forum";
|
||||
|
||||
public string TitleHint
|
||||
{
|
||||
get => _titleHint;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _titleHint, value ?? "")) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TechByDefault
|
||||
{
|
||||
get => _techByDefault;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _techByDefault, value)) _changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Type masqué : plus proposé aux joueurs (les demandes déjà reçues restent consultables).</summary>
|
||||
public bool Hidden
|
||||
{
|
||||
get => _hidden;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _hidden, value)) return;
|
||||
OnPropertyChanged(nameof(StateText));
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Différent des formulaires publiés (calculé par la page).</summary>
|
||||
public bool IsDirty
|
||||
{
|
||||
get => _isDirty;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isDirty, value)) OnPropertyChanged(nameof(StateText));
|
||||
}
|
||||
}
|
||||
|
||||
public string StateText => (_hidden ? "Masqué" : "Proposé aux joueurs") + (IsNew ? " · nouveau" : _isDirty ? " · modifié" : "");
|
||||
|
||||
public string FieldsText => Fields.Count switch
|
||||
{
|
||||
0 => "Aucun champ (titre seulement)",
|
||||
1 => "1 champ",
|
||||
var n => $"{n} champs"
|
||||
};
|
||||
|
||||
public ObservableCollection<FeedbackFieldEditor> Fields { get; } = new();
|
||||
|
||||
/// <summary>Types proposés par « Ajouter un champ ».</summary>
|
||||
public IReadOnlyList<FeedbackFieldKindChoice> FieldKinds { get; }
|
||||
|
||||
private bool _addFieldsEnabled = true;
|
||||
|
||||
public bool AddFieldsEnabled
|
||||
{
|
||||
get => _addFieldsEnabled;
|
||||
private set => SetProperty(ref _addFieldsEnabled, value);
|
||||
}
|
||||
|
||||
/// <summary>Formulaire tel qu'il sera publié.</summary>
|
||||
public FeedbackForm Build() => new(
|
||||
Kind,
|
||||
ApiText.CleanLine(_label, FeedbackFormRules.MaxFormLabelLength) ?? _label,
|
||||
ApiText.CleanLine(_description, FeedbackFormRules.MaxDescriptionLength) ?? _description,
|
||||
IconKey,
|
||||
ApiText.CleanLine(_titleHint, FeedbackFormRules.MaxTitleHintLength) ?? _titleHint,
|
||||
_techByDefault,
|
||||
Fields.Select(f => f.Build()).ToList(),
|
||||
_hidden,
|
||||
_allowAttachments,
|
||||
_customIcon);
|
||||
|
||||
/// <summary>Nouveau champ (identifiant généré, jamais réutilisé dans ce type), déplié et placé à la fin.</summary>
|
||||
private void AddField(FeedbackFieldKind kind)
|
||||
{
|
||||
if (Fields.Count >= FeedbackFormRules.MaxFields) return;
|
||||
var prefix = kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => "texte",
|
||||
FeedbackFieldKind.Text => "ligne",
|
||||
FeedbackFieldKind.Choice => "liste",
|
||||
FeedbackFieldKind.Checkboxes => "cases",
|
||||
FeedbackFieldKind.YesNo => "oui_non",
|
||||
_ => "note"
|
||||
};
|
||||
string id;
|
||||
do id = $"{prefix}_{++_fieldCounter}";
|
||||
while (Fields.Any(f => f.Id == id) || _publishedIds.Contains(id));
|
||||
|
||||
var field = new FeedbackField(id, FeedbackFieldEditor.KindLabel(kind), kind, Required: false,
|
||||
MaxLength: kind == FeedbackFieldKind.Text ? FeedbackFormRules.DefaultTextLength : FeedbackRules.MaxMultilineLength,
|
||||
Options: kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes
|
||||
? new[] { new FeedbackOption("choix_1", "Choix 1"), new FeedbackOption("choix_2", "Choix 2") }
|
||||
: null);
|
||||
Fields.Add(NewField(field, isNew: true));
|
||||
}
|
||||
|
||||
private FeedbackFieldEditor NewField(FeedbackField field, bool isNew) =>
|
||||
new(field, isNew, _changed, MoveField, f => Fields.Remove(f));
|
||||
|
||||
private void MoveField(FeedbackFieldEditor field, int delta)
|
||||
{
|
||||
var index = Fields.IndexOf(field);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= Fields.Count) return;
|
||||
Fields.Move(index, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Modules.Feedback;
|
||||
using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Admin.Feedback;
|
||||
|
||||
/// <summary>
|
||||
/// Page « Formulaires des demandes » (permission feedback.forms) : les types de demande du bouton « Aide » (1 à 8,
|
||||
/// communs à tous les jeux, dans l'ordre d'affichage aux joueurs) et leurs champs.
|
||||
/// • à gauche, les types (ajout, ordre, suppression ; proposé / masqué, modifié) ; au centre, la présentation du type
|
||||
/// (nom, icône d'origine ou importée depuis un fichier .ico 64 × 64, captures d'écran autorisées…), ses champs
|
||||
/// (ajout, ordre, retrait, libellé, aide, obligatoire, longueur, étoiles, choix) ; à droite, l'aperçu tel que les
|
||||
/// joueurs le verront ;
|
||||
/// • contrôle en direct avec les règles des formulaires (FeedbackFormRules : celles du serveur et des launchers) :
|
||||
/// aucun lien dans les textes, limites ; avertissement (sans refus) si un libellé semble demander des données
|
||||
/// personnelles — les demandes sont anonymes ;
|
||||
/// • « Publier » : avertissement si la clé de ce PC n'a pas l'autorisation « formulaires » dans le trousseau publié,
|
||||
/// formulaires signés par la clé de ce PC, requête signée, révision attendue (409 si quelqu'un a publié entre-temps :
|
||||
/// rien n'est écrasé) ; « Annuler les modifications », « Revenir aux formulaires d'origine ».
|
||||
/// </summary>
|
||||
public sealed class FeedbackFormsPanelViewModel : AdminPanelViewModel
|
||||
{
|
||||
public const string PanelId = "feedback.forms";
|
||||
|
||||
private FeedbackFormsState? _state;
|
||||
private string _publishedJson = "";
|
||||
private Dictionary<string, string> _publishedForms = new();
|
||||
private FeedbackFormEditor? _selectedForm;
|
||||
private string? _statusMessage;
|
||||
private bool _hasConflict;
|
||||
private bool _isDirty;
|
||||
private bool _suspendChanges;
|
||||
private IReadOnlyList<string> _validationErrors = Array.Empty<string>();
|
||||
private IReadOnlyList<string> _warnings = Array.Empty<string>();
|
||||
|
||||
public FeedbackFormsPanelViewModel(IAdminContext context) : base(context)
|
||||
{
|
||||
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
|
||||
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync),
|
||||
() => !IsBusy && _isDirty && !_hasConflict && _validationErrors.Count == 0 && _state is { PublishingEnabled: true });
|
||||
DiscardCommand = new RelayCommand(Discard, () => !IsBusy && _isDirty);
|
||||
RestoreOriginalCommand = new RelayCommand(RestoreOriginal, () => !IsBusy && _state is not null);
|
||||
AddFormCommand = new RelayCommand(AddForm, () => !IsBusy && _state is not null && Forms.Count < FeedbackFormRules.MaxForms);
|
||||
Forms.CollectionChanged += (_, _) =>
|
||||
{
|
||||
AddFormCommand.RaiseCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(FormsText));
|
||||
OnEdited();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Ajouter un type de demande (8 au plus).</summary>
|
||||
public RelayCommand AddFormCommand { get; }
|
||||
|
||||
public string FormsText => $"Types de demande · {Forms.Count} / {FeedbackFormRules.MaxForms}";
|
||||
|
||||
public override bool HasSplitLayout => Forms.Count > 0;
|
||||
|
||||
public ObservableCollection<FeedbackFormEditor> Forms { get; } = new();
|
||||
|
||||
public FeedbackFormEditor? SelectedForm
|
||||
{
|
||||
get => _selectedForm;
|
||||
set
|
||||
{
|
||||
if (value is null || !SetProperty(ref _selectedForm, value)) return;
|
||||
RefreshPreview();
|
||||
}
|
||||
}
|
||||
|
||||
public AsyncRelayCommand ReloadCommand { get; }
|
||||
public AsyncRelayCommand PublishCommand { get; }
|
||||
public RelayCommand DiscardCommand { get; }
|
||||
public RelayCommand RestoreOriginalCommand { get; }
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get => _statusMessage;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
|
||||
|
||||
public bool HasConflict
|
||||
{
|
||||
get => _hasConflict;
|
||||
private set
|
||||
{
|
||||
if (SetProperty(ref _hasConflict, value)) PublishCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Modifications non publiées.</summary>
|
||||
public bool IsDirty => _isDirty;
|
||||
|
||||
public bool IsEditable => !IsBusy;
|
||||
|
||||
/// <summary>Publication non configurée sur le serveur (pas de fichier public du trousseau).</summary>
|
||||
public bool ShowPublishingDisabled => _state is { PublishingEnabled: false };
|
||||
|
||||
public string PublishedText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_state is null) return "";
|
||||
var document = _state.Document;
|
||||
if (document.Revision == 0) return "Formulaires d'origine : rien n'a encore été publié depuis ce menu.";
|
||||
var when = document.UpdatedAt?.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
|
||||
return $"Révision {document.Revision} publiée" + (when is null ? "" : $" le {when}") +
|
||||
(_state.UpdatedBy is null ? "" : $" par {_state.UpdatedBy}") + ".";
|
||||
}
|
||||
}
|
||||
|
||||
public string? PublicUrl => _state?.PublicUrl;
|
||||
|
||||
// ------------------------------------------------------------------ Contrôles
|
||||
|
||||
/// <summary>Erreurs qui empêchent la publication (règles des formulaires, celles du serveur et des launchers).</summary>
|
||||
public IReadOnlyList<string> ValidationErrors
|
||||
{
|
||||
get => _validationErrors;
|
||||
private set
|
||||
{
|
||||
_validationErrors = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasValidationErrors));
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasValidationErrors => _validationErrors.Count > 0;
|
||||
|
||||
/// <summary>Avertissements (sans refus) : libellé qui semble demander des données personnelles, type masqué…</summary>
|
||||
public IReadOnlyList<string> Warnings
|
||||
{
|
||||
get => _warnings;
|
||||
private set
|
||||
{
|
||||
_warnings = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasWarnings));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasWarnings => _warnings.Count > 0;
|
||||
|
||||
// ------------------------------------------------------------------ Aperçu (mêmes champs que la fenêtre « Aide »)
|
||||
|
||||
/// <summary>Champs du type sélectionné tels que les joueurs les verront (saisie possible, rien n'est envoyé).</summary>
|
||||
public ObservableCollection<FeedbackFieldViewModel> Preview { get; } = new();
|
||||
|
||||
public string PreviewTitle => _selectedForm is null ? "" : $"Aperçu · {_selectedForm.Title}";
|
||||
|
||||
public string PreviewTitleHint => _selectedForm?.TitleHint ?? "";
|
||||
|
||||
public bool PreviewTechByDefault => _selectedForm?.TechByDefault ?? false;
|
||||
|
||||
public string PreviewNote => _selectedForm switch
|
||||
{
|
||||
{ Hidden: true } => "Ce type est masqué : les joueurs ne le voient pas.",
|
||||
{ AllowAttachments: true } => "Les joueurs voient aussi les captures d'écran (3 au plus) et les informations techniques.",
|
||||
_ => "Captures d'écran non proposées pour ce type. Les joueurs voient aussi les informations techniques."
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
protected override async Task OnActivatedAsync(bool firstTime)
|
||||
{
|
||||
if (firstTime) await ReloadAsync();
|
||||
}
|
||||
|
||||
private Task ReloadAsync()
|
||||
{
|
||||
if (_isDirty && !Context.Dialogs.Confirm("Abandonner les modifications ?",
|
||||
"Les formulaires modifiés n'ont pas été publiés : les modifications seront perdues.", "Abandonner"))
|
||||
return Task.CompletedTask;
|
||||
return RunAsync(async () =>
|
||||
{
|
||||
StatusMessage = null;
|
||||
Apply(await Context.Session.GetFeedbackFormsAsync());
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Formulaires en vigueur sur le serveur : éditeurs reconstruits, rien de modifié.</summary>
|
||||
private void Apply(FeedbackFormsState state)
|
||||
{
|
||||
_state = state;
|
||||
HasConflict = false;
|
||||
_publishedForms = state.Document.Forms.ToDictionary(f => f.Kind, Snapshot, StringComparer.Ordinal);
|
||||
_publishedJson = Snapshot(state.Document.Forms);
|
||||
LoadEditors(state.Document.Forms);
|
||||
foreach (var name in new[] { nameof(ShowPublishingDisabled), nameof(PublishedText), nameof(PublicUrl) }) OnPropertyChanged(name);
|
||||
RestoreOriginalCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
private void LoadEditors(IEnumerable<FeedbackForm> forms)
|
||||
{
|
||||
var selectedKind = _selectedForm?.Kind;
|
||||
_suspendChanges = true;
|
||||
try
|
||||
{
|
||||
Forms.Clear();
|
||||
// Ordre du document : celui affiché aux joueurs.
|
||||
foreach (var form in forms)
|
||||
Forms.Add(new FeedbackFormEditor(form, OnEdited, EditorActions, isNew: !_publishedForms.ContainsKey(form.Kind)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suspendChanges = false;
|
||||
}
|
||||
_selectedForm = Forms.FirstOrDefault(f => f.Kind == selectedKind) ?? Forms.FirstOrDefault();
|
||||
OnPropertyChanged(nameof(SelectedForm));
|
||||
SplitLayoutChanged();
|
||||
OnEdited();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Modifications
|
||||
|
||||
/// <summary>Une valeur a changé : état « modifié », contrôles et aperçu recalculés.</summary>
|
||||
private void OnEdited()
|
||||
{
|
||||
if (_suspendChanges) return;
|
||||
OnPropertyChanged(nameof(PreviewNote));
|
||||
var forms = Forms.Select(f => f.Build()).ToList();
|
||||
foreach (var (editor, form) in Forms.Zip(forms))
|
||||
editor.IsDirty = !_publishedForms.TryGetValue(form.Kind, out var published) || published != Snapshot(form);
|
||||
SetDirty(_state is not null && Snapshot(forms) != _publishedJson);
|
||||
|
||||
var errors = new List<string>();
|
||||
FeedbackFormRules.Validate(new FeedbackFormsDocument { Revision = (_state?.Document.Revision ?? 0) + 1, Forms = forms }, errors);
|
||||
ValidationErrors = errors.Distinct().ToList();
|
||||
Warnings = WarningsOf(forms);
|
||||
RefreshPreview();
|
||||
}
|
||||
|
||||
private void SetDirty(bool value)
|
||||
{
|
||||
if (_isDirty == value) return;
|
||||
_isDirty = value;
|
||||
OnPropertyChanged(nameof(IsDirty));
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
DiscardCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
/// <summary>Avertissements sans refus : données personnelles demandées (les demandes sont anonymes), tout masqué sauf un…</summary>
|
||||
private static IReadOnlyList<string> WarningsOf(IReadOnlyList<FeedbackForm> forms)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
foreach (var form in forms)
|
||||
{
|
||||
var texts = new[] { form.Label, form.Description, form.TitleHint }
|
||||
.Concat(form.Fields.SelectMany(f => new[] { f.Label, f.Hint ?? "" }.Concat(f.Choices.Select(o => o.Label))));
|
||||
var words = FeedbackFormRules.PersonalDataWords
|
||||
.Where(w => texts.Any(t => t.Contains(w, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
if (words.Count > 0)
|
||||
warnings.Add($"« {form.Label} » mentionne « {string.Join(" », « ", words)} » : les demandes sont anonymes, ne demandez " +
|
||||
"aucune donnée personnelle (adresse e-mail, mot de passe, identifiant de compte, téléphone…).");
|
||||
if (!form.Hidden && form.Fields.Count == 0)
|
||||
warnings.Add($"« {form.Label} » n'a aucun champ : les joueurs n'écriront qu'un titre.");
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private void RefreshPreview()
|
||||
{
|
||||
Preview.Clear();
|
||||
if (_selectedForm is not null)
|
||||
foreach (var field in _selectedForm.Build().Fields) Preview.Add(new FeedbackFieldViewModel(field));
|
||||
foreach (var name in new[] { nameof(PreviewTitle), nameof(PreviewTitleHint), nameof(PreviewTechByDefault), nameof(PreviewNote) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
|
||||
private void Discard()
|
||||
{
|
||||
if (_state is null) return;
|
||||
if (!Context.Dialogs.Confirm("Annuler les modifications ?",
|
||||
"Les formulaires reviennent à ceux actuellement publiés. Les modifications non publiées seront perdues.", "Annuler les modifications"))
|
||||
return;
|
||||
StatusMessage = null;
|
||||
LoadEditors(_state.Document.Forms);
|
||||
}
|
||||
|
||||
/// <summary>Formulaires d'origine dans l'éditeur (à publier ensuite) : les quatre types, leurs champs d'origine.</summary>
|
||||
private void RestoreOriginal()
|
||||
{
|
||||
if (!Context.Dialogs.Confirm("Revenir aux formulaires d'origine ?",
|
||||
"Les types de demande redeviennent les quatre d'origine (avis, demande d'évolution, bug, autre), avec leurs noms, icônes " +
|
||||
"et champs d'origine ; les types ajoutés disparaissent. " +
|
||||
"Rien n'est envoyé avant « Publier » ; les demandes déjà reçues ne changent pas.", "Revenir à l'origine"))
|
||||
return;
|
||||
StatusMessage = null;
|
||||
LoadEditors(FeedbackForms.All);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Types de demande (ajout, ordre, suppression, icône)
|
||||
|
||||
private FeedbackFormEditorActions EditorActions => new(MoveForm, RemoveForm, ImportIcon);
|
||||
|
||||
/// <summary>
|
||||
/// Nouveau type de demande (identifiant généré, jamais celui d'un type publié ou d'origine), avec un champ « Message »
|
||||
/// obligatoire ; placé à la fin et sélectionné.
|
||||
/// </summary>
|
||||
private void AddForm()
|
||||
{
|
||||
if (Forms.Count >= FeedbackFormRules.MaxForms) return;
|
||||
var taken = Forms.Select(f => f.Kind).Concat(_publishedForms.Keys).Concat(FeedbackForms.All.Select(f => f.Kind))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var n = 1;
|
||||
while (taken.Contains($"type_{n}")) n++;
|
||||
var form = new FeedbackForm($"type_{n}", "Nouveau type", "Décrivez votre demande.", "Forum", "", TechByDefault: false,
|
||||
new[] { new FeedbackField("message", "Message", FeedbackFieldKind.Multiline, Required: true) });
|
||||
var editor = new FeedbackFormEditor(form, OnEdited, EditorActions, isNew: true);
|
||||
Forms.Add(editor);
|
||||
SelectedForm = editor;
|
||||
}
|
||||
|
||||
private void MoveForm(FeedbackFormEditor form, int delta)
|
||||
{
|
||||
var index = Forms.IndexOf(form);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= Forms.Count) return;
|
||||
Forms.Move(index, target);
|
||||
}
|
||||
|
||||
private void RemoveForm(FeedbackFormEditor form)
|
||||
{
|
||||
if (Forms.Count <= 1) return;
|
||||
if (!Context.Dialogs.Confirm("Supprimer ce type de demande ?",
|
||||
$"« {form.Title} » ne sera plus proposé aux joueurs après la publication. Les demandes déjà reçues de ce type restent " +
|
||||
"consultables (avec leur formulaire d'alors). Pour le retirer seulement un temps, masquez-le plutôt.",
|
||||
"Supprimer", danger: true))
|
||||
return;
|
||||
var index = Forms.IndexOf(form);
|
||||
Forms.Remove(form);
|
||||
if (ReferenceEquals(_selectedForm, form)) SelectedForm = Forms[Math.Min(index, Forms.Count - 1)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Icône importée : fichier .ico OBLIGATOIREMENT, contenant une image de 64 × 64 pixels, convertie en PNG neuf sans
|
||||
/// métadonnées (<see cref="FeedbackIcons.FromIco"/>) ; tout autre fichier est refusé avec la raison.
|
||||
/// </summary>
|
||||
private void ImportIcon(FeedbackFormEditor form)
|
||||
{
|
||||
var path = Context.Dialogs.PickOpenFile($"Icône du type « {form.Title} » : fichier .ico contenant une image de {FeedbackIcons.Size} × {FeedbackIcons.Size} pixels",
|
||||
"Icône Windows (*.ico)|*.ico", null);
|
||||
if (path is null) return;
|
||||
string? error;
|
||||
byte[]? png = null;
|
||||
try
|
||||
{
|
||||
if (!string.Equals(Path.GetExtension(path), ".ico", StringComparison.OrdinalIgnoreCase))
|
||||
error = "Seuls les fichiers .ico sont acceptés.";
|
||||
else if (new FileInfo(path).Length > FeedbackIcons.MaxIcoBytes)
|
||||
error = $"Fichier trop volumineux (au plus {FeedbackIcons.MaxIcoBytes / 1024} Ko).";
|
||||
else
|
||||
error = FeedbackIcons.FromIco(File.ReadAllBytes(path), out png);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
error = $"Lecture du fichier impossible : {ex.Message}";
|
||||
}
|
||||
if (error is not null)
|
||||
{
|
||||
Context.Dialogs.ShowMessage("Icône refusée", error, ModuleMessageKind.Error);
|
||||
return;
|
||||
}
|
||||
form.SetCustomIcon(Convert.ToBase64String(png!));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Publication
|
||||
|
||||
private async Task PublishAsync()
|
||||
{
|
||||
if (_state is null || _validationErrors.Count > 0) return;
|
||||
var forms = Forms.Select(f => f.Build()).ToList();
|
||||
var hidden = forms.Where(f => f.Hidden).Select(f => $"« {f.Label} »").ToList();
|
||||
var message = "Les formulaires seront signés avec la clé de ce PC puis publiés : les launchers des joueurs les utiliseront " +
|
||||
"à la prochaine ouverture de la fenêtre « Aide ». Les demandes déjà reçues gardent leur formulaire d'origine." +
|
||||
(hidden.Count > 0 ? $"\n\nTypes masqués (plus proposés aux joueurs) : {string.Join(", ", hidden)}." : "");
|
||||
if (!Context.Dialogs.Confirm("Publier les formulaires ?", message, "Publier")) return;
|
||||
if (!await ConfirmPlayerSigningAsync(ContentKinds.FeedbackForms)) return;
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
StatusMessage = null;
|
||||
try
|
||||
{
|
||||
var state = await Context.Session.PublishFeedbackFormsAsync(forms, _state.Document.Revision);
|
||||
Apply(state);
|
||||
StatusMessage = $"Formulaires publiés (révision {state.Document.Revision}) : les joueurs les verront à la prochaine " +
|
||||
"ouverture de la fenêtre « Aide ».";
|
||||
}
|
||||
catch (ApiException ex) when (ex.Code == ApiErrorCodes.RevisionConflict)
|
||||
{
|
||||
HasConflict = true;
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override void OnBusyChanged()
|
||||
{
|
||||
ReloadCommand.RaiseCanExecuteChanged();
|
||||
PublishCommand.RaiseCanExecuteChanged();
|
||||
DiscardCommand.RaiseCanExecuteChanged();
|
||||
RestoreOriginalCommand.RaiseCanExecuteChanged();
|
||||
AddFormCommand.RaiseCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(IsEditable));
|
||||
}
|
||||
|
||||
private static string Snapshot(object value) => JsonSerializer.Serialize(value, ApiJson.Options);
|
||||
}
|
||||
@@ -71,8 +71,25 @@ public sealed class FeedbackItem : ObservableObject
|
||||
public string Reference => _summary.Reference;
|
||||
public string Title => _summary.Title;
|
||||
public string Kind => _summary.Kind;
|
||||
public string KindLabel => FeedbackForms.KindLabel(_summary.Kind);
|
||||
public string KindIconKey => FeedbackForms.Find(_summary.Kind)?.IconKey ?? "Forum";
|
||||
/// <summary>Formulaire publié du type (nom, icône) ; null : formulaires d'origine.</summary>
|
||||
public FeedbackForm? Form
|
||||
{
|
||||
get => _form;
|
||||
set
|
||||
{
|
||||
_form = value;
|
||||
foreach (var name in new[] { nameof(KindLabel), nameof(KindIconKey), nameof(KindCustomIcon), nameof(HasKindCustomIcon), nameof(DetailText) })
|
||||
OnPropertyChanged(name);
|
||||
}
|
||||
}
|
||||
private FeedbackForm? _form;
|
||||
|
||||
public string KindLabel => _form?.Label ?? FeedbackForms.KindLabel(_summary.Kind);
|
||||
public string KindIconKey => _form?.IconKey ?? FeedbackForms.Find(_summary.Kind)?.IconKey ?? "Forum";
|
||||
|
||||
/// <summary>Icône importée du type (PNG 64 × 64) ; null : icône d'origine.</summary>
|
||||
public byte[]? KindCustomIcon => _form?.CustomIconBytes();
|
||||
public bool HasKindCustomIcon => KindCustomIcon is not null;
|
||||
public string Status => _summary.Status;
|
||||
public string StatusLabel => FeedbackStatuses.Label(_summary.Status);
|
||||
public bool IsNew => _summary.Status == FeedbackStatuses.New;
|
||||
@@ -242,8 +259,13 @@ public sealed class FeedbackDetailView : ObservableObject
|
||||
|
||||
public string Title => _detail.Summary.Title;
|
||||
public string Reference => _detail.Summary.Reference;
|
||||
public string KindLabel => FeedbackForms.KindLabel(_detail.Summary.Kind);
|
||||
public string KindIconKey => FeedbackForms.Find(_detail.Summary.Kind)?.IconKey ?? "Forum";
|
||||
/// <summary>Nom du type tel qu'à l'envoi (formulaires publiés depuis le menu), sinon nom d'origine.</summary>
|
||||
public string KindLabel => _detail.Form?.Label ?? FeedbackForms.KindLabel(_detail.Summary.Kind);
|
||||
public string KindIconKey => _detail.Form?.IconKey ?? FeedbackForms.Find(_detail.Summary.Kind)?.IconKey ?? "Forum";
|
||||
|
||||
/// <summary>Icône importée du type tel qu'à l'envoi ; null : icône d'origine.</summary>
|
||||
public byte[]? KindCustomIcon => _detail.Form?.CustomIconBytes();
|
||||
public bool HasKindCustomIcon => KindCustomIcon is not null;
|
||||
|
||||
public string ReceivedText =>
|
||||
$"Reçue le {_detail.Summary.ReceivedAt.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)}";
|
||||
@@ -299,14 +321,18 @@ public sealed class FeedbackDetailView : ObservableObject
|
||||
/// <summary>Note telle qu'envoyée (vide : null).</summary>
|
||||
public string? NoteToSend => string.IsNullOrWhiteSpace(_note) ? null : _note;
|
||||
|
||||
/// <summary>
|
||||
/// Champs avec les libellés du formulaire TEL QU'À L'ENVOI (conservé avec la demande), ou ceux d'origine pour une
|
||||
/// demande reçue avant la 3.2 : une demande reste lisible même si le formulaire a changé depuis.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<FeedbackFieldLine> BuildLines(FeedbackDetail detail)
|
||||
{
|
||||
var form = FeedbackForms.Find(detail.Summary.Kind);
|
||||
var form = detail.Form ?? FeedbackForms.Find(detail.Summary.Kind);
|
||||
var lines = new List<FeedbackFieldLine>();
|
||||
foreach (var field in form?.Fields ?? Array.Empty<FeedbackField>())
|
||||
if (detail.Fields.TryGetValue(field.Id, out var value) && !string.IsNullOrEmpty(value))
|
||||
lines.Add(new FeedbackFieldLine(field.Label, field.DisplayValue(value), field.Kind == FeedbackFieldKind.Multiline,
|
||||
field.Kind == FeedbackFieldKind.Multiline && FeedbackLinks.Contains(value)));
|
||||
field.IsText && FeedbackLinks.Contains(value)));
|
||||
// Champ d'une version plus récente du formulaire, inconnu de ce launcher : affiché tel quel.
|
||||
foreach (var (id, value) in detail.Fields.Where(f => form?.Field(f.Key) is null))
|
||||
lines.Add(new FeedbackFieldLine(id, value, value.Length > 60 || value.Contains('\n'), FeedbackLinks.Contains(value)));
|
||||
|
||||
@@ -46,8 +46,8 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
|
||||
context.Games.First(g => g.GameId == c.Value).GameName, choice => SelectedGame = choice), StringComparer.OrdinalIgnoreCase);
|
||||
GameTiles = _tiles.Values.ToList();
|
||||
|
||||
Kinds = new[] { new Choice(null, "Tous les types") }
|
||||
.Concat(FeedbackForms.All.Select(f => new Choice(f.Kind, f.Label))).ToList();
|
||||
Kinds = new ObservableCollection<Choice>(new[] { new Choice(null, "Tous les types") }
|
||||
.Concat(FeedbackForms.All.Select(f => new Choice(f.Kind, f.Label))));
|
||||
Statuses = new[]
|
||||
{
|
||||
new Choice("open", "À traiter (nouvelles et en cours)"),
|
||||
@@ -121,7 +121,42 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
|
||||
|
||||
// ------------------------------------------------------------------ Filtres et liste
|
||||
|
||||
public IReadOnlyList<Choice> Kinds { get; }
|
||||
/// <summary>Types de demande du filtre : ceux publiés depuis le menu (même masqués), puis ceux d'origine absents.</summary>
|
||||
public ObservableCollection<Choice> Kinds { get; }
|
||||
|
||||
/// <summary>Formulaires publiés (noms et icônes des types dans la liste) ; vide : formulaires d'origine.</summary>
|
||||
private Dictionary<string, FeedbackForm> _publishedForms = new(StringComparer.Ordinal);
|
||||
|
||||
private FeedbackForm? FormOf(string kind) =>
|
||||
_publishedForms.TryGetValue(kind, out var form) ? form : FeedbackForms.Find(kind);
|
||||
|
||||
/// <summary>
|
||||
/// Noms et icônes des types publiés depuis la page « Formulaires des demandes » (lecture permise à tout
|
||||
/// administrateur : contenu public). En cas d'échec, les noms d'origine restent affichés.
|
||||
/// </summary>
|
||||
private async Task LoadKindsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var state = await Context.Session.GetFeedbackFormsAsync();
|
||||
if (state.Document.Revision == 0) return;
|
||||
_publishedForms = state.Document.Forms.ToDictionary(f => f.Kind, StringComparer.Ordinal);
|
||||
}
|
||||
catch (ApiException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var selected = _kind.Value;
|
||||
var choices = new[] { new Choice(null, "Tous les types") }
|
||||
.Concat(_publishedForms.Values.Select(f => new Choice(f.Kind, f.Hidden ? $"{f.Label} (masqué)" : f.Label)))
|
||||
.Concat(FeedbackForms.All.Where(f => !_publishedForms.ContainsKey(f.Kind)).Select(f => new Choice(f.Kind, $"{f.Label} (supprimé)")))
|
||||
.ToList();
|
||||
Kinds.Clear();
|
||||
foreach (var choice in choices) Kinds.Add(choice);
|
||||
_kind = Kinds.FirstOrDefault(k => k.Value == selected) ?? Kinds[0];
|
||||
OnPropertyChanged(nameof(Kind));
|
||||
foreach (var item in _loaded) item.Form = FormOf(item.Kind);
|
||||
}
|
||||
public IReadOnlyList<Choice> Statuses { get; }
|
||||
|
||||
public Choice Kind
|
||||
@@ -262,7 +297,12 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
|
||||
DeleteCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
|
||||
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
|
||||
protected override async Task OnActivatedAsync(bool firstTime)
|
||||
{
|
||||
if (!firstTime) return;
|
||||
await LoadKindsAsync();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Chargement
|
||||
|
||||
@@ -354,7 +394,7 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
|
||||
|
||||
private void AddPage(FeedbackPage page)
|
||||
{
|
||||
_loaded.AddRange(page.Items.Select(s => new FeedbackItem(s)));
|
||||
_loaded.AddRange(page.Items.Select(s => new FeedbackItem(s) { Form = FormOf(s.Kind) }));
|
||||
_nextCursor = page.NextCursor;
|
||||
_total = page.Total;
|
||||
ApplySearch();
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
|
||||
<!-- Aperçu des formulaires des demandes : mêmes champs que la fenêtre « Aide » des joueurs -->
|
||||
<ProjectReference Include="..\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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}";
|
||||
});
|
||||
}
|
||||
@@ -3,20 +3,23 @@ using GameLauncher.Mvvm;
|
||||
|
||||
namespace GameLauncher.Modules.Feedback;
|
||||
|
||||
/// <summary>Étoile d'un champ « note » (1 à 5).</summary>
|
||||
/// <summary>Étoile d'un champ « note » (1 à 3, 5 ou 10 étoiles).</summary>
|
||||
public sealed class RatingStar : ObservableObject
|
||||
{
|
||||
private bool _isOn;
|
||||
|
||||
public RatingStar(int value, Action<int> select)
|
||||
public RatingStar(int value, Action<int> select, int max = 5)
|
||||
{
|
||||
Value = value;
|
||||
Max = max;
|
||||
SelectCommand = new RelayCommand(() => select(value));
|
||||
}
|
||||
|
||||
public int Value { get; }
|
||||
public int Max { get; }
|
||||
|
||||
public string ToolTip => Value switch
|
||||
/// <summary>Appréciation (sur 5 étoiles), sinon « 7 / 10 ».</summary>
|
||||
public string ToolTip => Max != 5 ? $"{Value} / {Max}" : Value switch
|
||||
{
|
||||
1 => "1 étoile : décevant",
|
||||
2 => "2 étoiles : moyen",
|
||||
@@ -34,7 +37,35 @@ public sealed class RatingStar : ObservableObject
|
||||
public RelayCommand SelectCommand { get; }
|
||||
}
|
||||
|
||||
/// <summary>Champ du formulaire, généré à partir de sa description partagée (<see cref="FeedbackField"/>).</summary>
|
||||
/// <summary>Case d'un champ « cases à cocher ».</summary>
|
||||
public sealed class FeedbackCheckOption : ObservableObject
|
||||
{
|
||||
private readonly Action _changed;
|
||||
private bool _isChecked;
|
||||
|
||||
public FeedbackCheckOption(FeedbackOption option, Action changed)
|
||||
{
|
||||
Option = option;
|
||||
_changed = changed;
|
||||
}
|
||||
|
||||
public FeedbackOption Option { get; }
|
||||
public string Label => Option.Label;
|
||||
|
||||
public bool IsChecked
|
||||
{
|
||||
get => _isChecked;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isChecked, value)) _changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Champ du formulaire, généré à partir de sa description (<see cref="FeedbackField"/>, formulaires publiés ou
|
||||
/// d'origine) : zone de texte, texte court, liste déroulante, cases à cocher, case oui/non ou note en étoiles.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFieldViewModel : ObservableObject
|
||||
{
|
||||
/// <summary>Choix « non précisé » des listes facultatives.</summary>
|
||||
@@ -43,6 +74,7 @@ public sealed class FeedbackFieldViewModel : ObservableObject
|
||||
private string _text = "";
|
||||
private FeedbackOption? _selectedOption;
|
||||
private int _rating;
|
||||
private bool _isChecked;
|
||||
private string? _error;
|
||||
|
||||
public FeedbackFieldViewModel(FeedbackField field)
|
||||
@@ -50,17 +82,33 @@ public sealed class FeedbackFieldViewModel : ObservableObject
|
||||
Field = field;
|
||||
Options = field.Required ? field.Choices : new[] { NoChoice }.Concat(field.Choices).ToList();
|
||||
if (!field.Required) _selectedOption = NoChoice;
|
||||
Stars = Enumerable.Range(1, 5).Select(i => new RatingStar(i, value => Rating = value)).ToList();
|
||||
var max = field.Kind == FeedbackFieldKind.Rating ? Math.Clamp(field.RatingMax, 1, 10) : 5;
|
||||
Stars = Enumerable.Range(1, max).Select(i => new RatingStar(i, value => Rating = value, max)).ToList();
|
||||
CheckOptions = field.Kind == FeedbackFieldKind.Checkboxes
|
||||
? field.Choices.Select(o => new FeedbackCheckOption(o, () => Error = null)).ToList()
|
||||
: Array.Empty<FeedbackCheckOption>();
|
||||
}
|
||||
|
||||
public FeedbackField Field { get; }
|
||||
public string Id => Field.Id;
|
||||
|
||||
/// <summary>Libellé (« * » : obligatoire ; une case oui/non porte son libellé à côté de la case).</summary>
|
||||
public string Label => Field.Required ? Field.Label + " *" : Field.Label;
|
||||
public string? Hint => Field.Hint;
|
||||
|
||||
public bool IsMultiline => Field.Kind == FeedbackFieldKind.Multiline;
|
||||
public bool IsText => Field.Kind == FeedbackFieldKind.Text;
|
||||
public bool IsChoice => Field.Kind == FeedbackFieldKind.Choice;
|
||||
public bool IsRating => Field.Kind == FeedbackFieldKind.Rating;
|
||||
public bool IsCheckboxes => Field.Kind == FeedbackFieldKind.Checkboxes;
|
||||
public bool IsYesNo => Field.Kind == FeedbackFieldKind.YesNo;
|
||||
|
||||
/// <summary>Libellé au-dessus du champ (tous sauf la case oui/non, dont le libellé est à côté de la case).</summary>
|
||||
public bool ShowLabel => !IsYesNo;
|
||||
|
||||
/// <summary>Aide affichée sous le champ (champs sans zone de saisie ; sinon elle sert de texte indicatif).</summary>
|
||||
public bool ShowHintText => !Field.IsText && !string.IsNullOrEmpty(Field.Hint);
|
||||
|
||||
public int MaxLength => Field.MaxLength;
|
||||
|
||||
public string Text
|
||||
@@ -91,14 +139,55 @@ public sealed class FeedbackFieldViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reprend la saisie d'un champ du même identifiant et du même type (formulaires republiés pendant la saisie) :
|
||||
/// seules les valeurs encore valables sont reprises (choix toujours proposés, note dans la nouvelle échelle).
|
||||
/// </summary>
|
||||
public void RestoreFrom(FeedbackFieldViewModel previous)
|
||||
{
|
||||
if (previous.Field.Kind != Field.Kind) return;
|
||||
switch (Field.Kind)
|
||||
{
|
||||
case FeedbackFieldKind.Choice:
|
||||
if (Options.FirstOrDefault(o => o.Id == previous.SelectedOption?.Id) is { } option) SelectedOption = option;
|
||||
break;
|
||||
case FeedbackFieldKind.Checkboxes:
|
||||
var chosen = previous.CheckOptions.Where(o => o.IsChecked).Select(o => o.Option.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var box in CheckOptions) box.IsChecked = chosen.Contains(box.Option.Id);
|
||||
break;
|
||||
case FeedbackFieldKind.Rating:
|
||||
if (previous.Rating <= Stars.Count) Rating = previous.Rating;
|
||||
break;
|
||||
case FeedbackFieldKind.YesNo:
|
||||
IsChecked = previous.IsChecked;
|
||||
break;
|
||||
default:
|
||||
Text = previous.Text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RatingStar> Stars { get; }
|
||||
|
||||
/// <summary>Cases d'un champ « cases à cocher ».</summary>
|
||||
public IReadOnlyList<FeedbackCheckOption> CheckOptions { get; }
|
||||
|
||||
/// <summary>Case oui/non cochée.</summary>
|
||||
public bool IsChecked
|
||||
{
|
||||
get => _isChecked;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isChecked, value)) Error = null;
|
||||
}
|
||||
}
|
||||
|
||||
public int Rating
|
||||
{
|
||||
get => _rating;
|
||||
set
|
||||
{
|
||||
if (!SetProperty(ref _rating, Math.Clamp(value, 0, 5))) return;
|
||||
if (!SetProperty(ref _rating, Math.Clamp(value, 0, Stars.Count))) return;
|
||||
foreach (var star in Stars) star.IsOn = star.Value <= _rating;
|
||||
OnPropertyChanged(nameof(RatingText));
|
||||
Error = null;
|
||||
@@ -124,6 +213,10 @@ public sealed class FeedbackFieldViewModel : ObservableObject
|
||||
{
|
||||
FeedbackFieldKind.Choice => string.IsNullOrEmpty(_selectedOption?.Id) ? null : _selectedOption!.Id,
|
||||
FeedbackFieldKind.Rating => _rating == 0 ? null : _rating.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
FeedbackFieldKind.Checkboxes => CheckOptions.Any(o => o.IsChecked)
|
||||
? string.Join(',', CheckOptions.Where(o => o.IsChecked).Select(o => o.Option.Id))
|
||||
: null,
|
||||
FeedbackFieldKind.YesNo => _isChecked ? FeedbackRules.Yes : FeedbackRules.No,
|
||||
_ => string.IsNullOrWhiteSpace(_text) ? null : _text
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ public sealed class FeedbackKindChoice
|
||||
public FeedbackKindChoice(FeedbackForm form, Action<FeedbackForm> select)
|
||||
{
|
||||
Form = form;
|
||||
CustomIcon = form.CustomIconBytes();
|
||||
SelectCommand = new RelayCommand(() => select(form));
|
||||
}
|
||||
|
||||
@@ -47,6 +48,10 @@ public sealed class FeedbackKindChoice
|
||||
public string Label => Form.Label;
|
||||
public string Description => Form.Description;
|
||||
public string IconKey => Form.IconKey;
|
||||
|
||||
/// <summary>Icône importée depuis le menu (PNG 64 × 64, vérifiée avec les formulaires signés) ; null : icône d'origine.</summary>
|
||||
public byte[]? CustomIcon { get; } = null;
|
||||
public bool HasCustomIcon => CustomIcon is not null;
|
||||
public RelayCommand SelectCommand { get; }
|
||||
}
|
||||
|
||||
@@ -59,8 +64,11 @@ public enum FeedbackStep
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fenêtre « Aide » : choix du type de demande, formulaire (généré à partir des formulaires partagés), envoi, référence.
|
||||
/// Fenêtre « Aide » : choix du type de demande, formulaire (généré à partir des formulaires publiés depuis le menu, ou
|
||||
/// de ceux d'origine), envoi, référence.
|
||||
/// • le jeu est celui sélectionné à l'ouverture, affiché et non modifiable ;
|
||||
/// • formulaires lus à l'ouverture (<see cref="IFeedbackFormsSource"/> : publiés et vérifiés, sinon d'origine) ; s'ils
|
||||
/// sont republiés pendant la saisie (409), ils sont relus et la saisie des champs restés identiques est reprise ;
|
||||
/// • contrôle avant l'envoi avec les règles du serveur (<see cref="FeedbackRules"/>) ;
|
||||
/// • en cas d'échec (réseau, serveur occupé…), la saisie est conservée et l'envoi peut être relancé ;
|
||||
/// • demande ANONYME : aucun identifiant du joueur ni du PC ; informations techniques facultatives, montrées avant l'envoi.
|
||||
@@ -69,6 +77,9 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly IFeedbackSender _sender;
|
||||
private readonly IScreenshotSource? _screenshots;
|
||||
private readonly IFeedbackFormsSource? _formsSource;
|
||||
private FeedbackFormsDocument _forms = FeedbackFormRules.Defaults();
|
||||
private bool _isLoadingForms;
|
||||
private string? _attachmentError;
|
||||
private string? _attachmentWarning;
|
||||
private readonly FeedbackTechInfo _tech;
|
||||
@@ -82,12 +93,14 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
|
||||
private string? _errorMessage;
|
||||
private string? _reference;
|
||||
|
||||
/// <param name="forms">Formulaires publiés (null : formulaires d'origine, affichés tout de suite).</param>
|
||||
public FeedbackFormViewModel(FeedbackTarget target, IFeedbackSender sender, FeedbackTechInfo? tech = null,
|
||||
IScreenshotSource? screenshots = null)
|
||||
IScreenshotSource? screenshots = null, IFeedbackFormsSource? forms = null)
|
||||
{
|
||||
Target = target;
|
||||
_sender = sender;
|
||||
_screenshots = screenshots;
|
||||
_formsSource = forms;
|
||||
AddFileCommand = new RelayCommand(() => AddScreenshot(_screenshots?.PickFile()), () => CanAddScreenshot);
|
||||
PasteCommand = new RelayCommand(() => AddScreenshot(_screenshots?.FromClipboard(), fromClipboard: true), () => CanAddScreenshot);
|
||||
Attachments.CollectionChanged += (_, _) =>
|
||||
@@ -99,10 +112,79 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
|
||||
PasteCommand.RaiseCanExecuteChanged();
|
||||
};
|
||||
_tech = tech ?? FeedbackTech.Collect(target.LauncherVersion, target.InstalledVersion);
|
||||
Kinds = FeedbackForms.All.Select(f => new FeedbackKindChoice(f, Choose)).ToList();
|
||||
SendCommand = new AsyncRelayCommand(SendAsync, () => _form is not null && !_isSending && _step == FeedbackStep.Fill);
|
||||
BackCommand = new RelayCommand(Back, () => !_isSending && _step == FeedbackStep.Fill);
|
||||
CloseCommand = new RelayCommand(() => CloseRequested?.Invoke());
|
||||
ApplyForms(_forms);
|
||||
FormsLoad = forms is null ? Task.CompletedTask : LoadFormsAsync(refresh: false);
|
||||
}
|
||||
|
||||
/// <summary>Lecture des formulaires lancée à l'ouverture (tests, captures d'écran de l'interface).</summary>
|
||||
public Task FormsLoad { get; private set; }
|
||||
|
||||
/// <summary>Révision des formulaires affichés (0 : formulaires d'origine), envoyée avec la demande.</summary>
|
||||
public long FormsRevision => _forms.Revision;
|
||||
|
||||
/// <summary>Formulaires en cours de lecture (les types de demande apparaissent ensuite).</summary>
|
||||
public bool IsLoadingForms
|
||||
{
|
||||
get => _isLoadingForms;
|
||||
private set => SetProperty(ref _isLoadingForms, value);
|
||||
}
|
||||
|
||||
private async Task LoadFormsAsync(bool refresh)
|
||||
{
|
||||
IsLoadingForms = true;
|
||||
try
|
||||
{
|
||||
ApplyForms(await _formsSource!.LoadAsync(refresh, CancellationToken.None));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Formulaires des demandes : {ex.Message} (formulaires d'origine utilisés).");
|
||||
ApplyForms(FeedbackFormRules.Defaults());
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingForms = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Types proposés : ceux non masqués des formulaires donnés.</summary>
|
||||
private void ApplyForms(FeedbackFormsDocument forms)
|
||||
{
|
||||
_forms = forms;
|
||||
Kinds.Clear();
|
||||
foreach (var form in forms.Visible) Kinds.Add(new FeedbackKindChoice(form, Choose));
|
||||
OnPropertyChanged(nameof(FormsRevision));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires republiés pendant la saisie : le type en cours est reconstruit avec sa nouvelle définition, et la
|
||||
/// saisie des champs de même identifiant et de même type est reprise. Type masqué depuis : retour au choix du type.
|
||||
/// </summary>
|
||||
private void RebuildCurrentForm()
|
||||
{
|
||||
if (_form is null) return;
|
||||
var updated = _forms.Visible.FirstOrDefault(f => f.Kind == _form.Kind);
|
||||
if (updated is null)
|
||||
{
|
||||
Form = null;
|
||||
Fields.Clear();
|
||||
Step = FeedbackStep.ChooseKind;
|
||||
ErrorMessage = "Ce type de demande n'est plus proposé : choisissez-en un autre.";
|
||||
return;
|
||||
}
|
||||
var previous = Fields.ToDictionary(f => f.Id, StringComparer.Ordinal);
|
||||
Form = updated;
|
||||
Fields.Clear();
|
||||
foreach (var field in updated.Fields)
|
||||
{
|
||||
var view = new FeedbackFieldViewModel(field);
|
||||
if (previous.TryGetValue(field.Id, out var old)) view.RestoreFrom(old);
|
||||
Fields.Add(view);
|
||||
}
|
||||
ErrorMessage = "Le formulaire vient d'être mis à jour : vérifiez votre saisie, puis cliquez de nouveau sur « Envoyer ».";
|
||||
}
|
||||
|
||||
public FeedbackTarget Target { get; }
|
||||
@@ -110,7 +192,7 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
|
||||
/// <summary>« Demande pour : Exovival Zombie Reborn ».</summary>
|
||||
public string GameText => $"Demande pour : {Target.GameName}";
|
||||
|
||||
public IReadOnlyList<FeedbackKindChoice> Kinds { get; }
|
||||
public ObservableCollection<FeedbackKindChoice> Kinds { get; } = new();
|
||||
|
||||
/// <summary>La fenêtre doit se fermer (« Fermer », « Annuler »).</summary>
|
||||
public event Action? CloseRequested;
|
||||
@@ -142,12 +224,22 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
|
||||
if (!SetProperty(ref _form, value)) return;
|
||||
OnPropertyChanged(nameof(FormTitle));
|
||||
OnPropertyChanged(nameof(FormIconKey));
|
||||
OnPropertyChanged(nameof(FormCustomIcon));
|
||||
OnPropertyChanged(nameof(HasFormCustomIcon));
|
||||
OnPropertyChanged(nameof(TitleHint));
|
||||
OnPropertyChanged(nameof(AttachmentsAllowed));
|
||||
OnPropertyChanged(nameof(CanAddScreenshot));
|
||||
AddFileCommand.RaiseCanExecuteChanged();
|
||||
PasteCommand.RaiseCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string FormTitle => _form?.Label ?? "Aide";
|
||||
public string FormIconKey => _form?.IconKey ?? "MessageQuestion";
|
||||
|
||||
/// <summary>Icône importée du type choisi (null : icône d'origine <see cref="FormIconKey"/>).</summary>
|
||||
public byte[]? FormCustomIcon => _form?.CustomIconBytes();
|
||||
public bool HasFormCustomIcon => FormCustomIcon is not null;
|
||||
public string TitleHint => _form?.TitleHint ?? "";
|
||||
|
||||
private void Choose(FeedbackForm form)
|
||||
@@ -228,8 +320,11 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
|
||||
/// <summary>Captures possibles (source disponible : fenêtre WPF).</summary>
|
||||
public bool ScreenshotsAvailable => _screenshots is not null;
|
||||
|
||||
/// <summary>Captures proposées : source disponible ET autorisées pour ce type de demande (réglage du menu).</summary>
|
||||
public bool AttachmentsAllowed => _screenshots is not null && (_form?.AllowAttachments ?? true);
|
||||
|
||||
public bool HasAttachments => Attachments.Count > 0;
|
||||
public bool CanAddScreenshot => _screenshots is not null && !_isSending && Attachments.Count < FeedbackImages.MaxAttachments;
|
||||
public bool CanAddScreenshot => AttachmentsAllowed && !_isSending && Attachments.Count < FeedbackImages.MaxAttachments;
|
||||
|
||||
public string AttachmentsText =>
|
||||
$"{Attachments.Count} / {FeedbackImages.MaxAttachments} · PNG, JPEG, BMP, GIF ou WebP · {FeedbackImages.MaxBytes / 1024 / 1024} Mo au plus après conversion";
|
||||
@@ -365,12 +460,13 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
|
||||
GameId = Target.GameId,
|
||||
Kind = _form.Kind,
|
||||
FormVersion = FeedbackForms.CurrentVersion,
|
||||
FormRevision = _forms.Revision,
|
||||
Title = _title,
|
||||
Fields = Fields.Where(f => f.Value is not null).ToDictionary(f => f.Id, f => f.Value!),
|
||||
Tech = _includeTech ? _tech : null
|
||||
};
|
||||
var errors = new List<string>();
|
||||
var clean = FeedbackRules.Validate(submission, errors);
|
||||
var clean = FeedbackRules.Validate(submission, _form, errors);
|
||||
if (clean is null) ErrorMessage = errors.FirstOrDefault();
|
||||
return clean;
|
||||
}
|
||||
@@ -390,10 +486,12 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
|
||||
_sending = new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
var sent = await _sender.SendAsync(submission, Attachments.Select(a => a.Content).ToList(), _sending.Token);
|
||||
// Captures seulement si ce type de demande les accepte (réglage du menu ; le serveur le revérifie).
|
||||
IReadOnlyList<byte[]> attachments = AttachmentsAllowed ? Attachments.Select(a => a.Content).ToList() : Array.Empty<byte[]>();
|
||||
var sent = await _sender.SendAsync(submission, attachments, _sending.Token);
|
||||
Reference = sent.Reference;
|
||||
AttachmentWarning = sent.AttachmentsSent < Attachments.Count
|
||||
? $"{Attachments.Count - sent.AttachmentsSent} capture(s) sur {Attachments.Count} n'ont pas pu être envoyées" +
|
||||
AttachmentWarning = sent.AttachmentsSent < attachments.Count
|
||||
? $"{attachments.Count - sent.AttachmentsSent} capture(s) sur {attachments.Count} n'ont pas pu être envoyées" +
|
||||
(sent.AttachmentError is null ? "." : $" ({sent.AttachmentError}).") + " La demande, elle, est bien enregistrée."
|
||||
: null;
|
||||
Log.Info($"[{Target.GameId}] Demande envoyée ({_form.Kind}) : {Reference}, {sent.AttachmentsSent} capture(s)");
|
||||
@@ -403,6 +501,12 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
// fenêtre fermée pendant l'envoi
|
||||
}
|
||||
catch (ApiException ex) when (ex.Code == ApiErrorCodes.FeedbackFormChanged && _formsSource is not null)
|
||||
{
|
||||
Log.Info($"[{Target.GameId}] Formulaires des demandes republiés pendant la saisie : relus.");
|
||||
await LoadFormsAsync(refresh: true);
|
||||
RebuildCurrentForm();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"[{Target.GameId}] Envoi d'une demande impossible : {ex.Message}");
|
||||
@@ -430,6 +534,8 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
|
||||
ApiException { Code: ApiErrorCodes.FeedbackFull } or ApiException { Status: >= 500 } =>
|
||||
"Le service des demandes est momentanément indisponible. Réessayez plus tard : votre saisie est conservée.",
|
||||
ApiException { Code: ApiErrorCodes.ProofInvalid } => "Vérification anti-spam échouée : cliquez de nouveau sur « Envoyer ».",
|
||||
ApiException { Code: ApiErrorCodes.FeedbackFormChanged } =>
|
||||
"Le formulaire vient d'être mis à jour : fermez puis rouvrez la fenêtre « Aide » (votre saisie est à refaire).",
|
||||
ApiException { Status: 400 } api => api.Message,
|
||||
_ => "L'envoi a échoué. Réessayez dans un instant : votre saisie est conservée."
|
||||
};
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Core.Utils;
|
||||
|
||||
namespace GameLauncher.Modules.Feedback;
|
||||
|
||||
/// <summary>Formulaires à proposer au joueur (publiés et vérifiés, sinon ceux d'origine).</summary>
|
||||
public interface IFeedbackFormsSource
|
||||
{
|
||||
/// <param name="refresh">Relire le fichier publié (formulaires republiés pendant la saisie : réponse 409 du serveur).</param>
|
||||
Task<FeedbackFormsDocument> LoadAsync(bool refresh, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires des demandes publiés depuis le menu d'administration (feedback-forms.json, à côté du trousseau), lus à
|
||||
/// l'ouverture de la fenêtre « Aide » seulement :
|
||||
/// • fichier borné en taille, contrôlé avec les règles des formulaires (même signé, un fichier hors limites est refusé) ;
|
||||
/// • signature vérifiée avec le trousseau (autorisation « forms »), retour en arrière refusé (révision et date de
|
||||
/// signature), selon le mode « contentSigning » du launcher, comme le catalogue ;
|
||||
/// • dernière copie acceptée gardée dans les données du launcher (feedback\forms.json), revérifiée à la lecture ;
|
||||
/// • rien de valide (serveur injoignable, rien de publié, signature refusée) : formulaires d'origine (révision 0),
|
||||
/// toujours acceptés par le serveur.
|
||||
/// </summary>
|
||||
public sealed class FeedbackFormsService : IFeedbackFormsSource
|
||||
{
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(8);
|
||||
|
||||
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
|
||||
private readonly Uri? _url;
|
||||
private readonly ContentTrustService? _trust;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private FeedbackFormsDocument? _loaded;
|
||||
|
||||
/// <param name="url">Adresse du fichier publié (null : formulaires d'origine seulement).</param>
|
||||
/// <param name="trust">Vérification des signatures (null : mode « off », formulaires utilisés sans signature).</param>
|
||||
public FeedbackFormsService(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? url, string dataDirectory,
|
||||
ContentTrustService? trust)
|
||||
{
|
||||
_download = download;
|
||||
_url = url;
|
||||
_trust = trust;
|
||||
CachePath = Path.Combine(dataDirectory, "feedback", "forms.json");
|
||||
}
|
||||
|
||||
public string CachePath { get; }
|
||||
|
||||
/// <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, "feedback-forms.json") : null;
|
||||
}
|
||||
|
||||
public async Task<FeedbackFormsDocument> LoadAsync(bool refresh, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_loaded is not null && !refresh) return _loaded;
|
||||
var cached = ReadCache();
|
||||
var fallback = cached ?? FeedbackFormRules.Defaults();
|
||||
if (_url is null) return _loaded = fallback;
|
||||
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(FetchTimeout);
|
||||
bytes = await _download(_url, FeedbackFormRules.MaxBytes, timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
Log.Info("Formulaires des demandes : aucun formulaire publié, formulaires d'origine utilisés.");
|
||||
return _loaded = fallback;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
|
||||
{
|
||||
// Réseau, délai dépassé, réponse trop grande : pas mémorisé, nouvel essai à la prochaine ouverture.
|
||||
Log.Warn($"Formulaires des demandes injoignables : {ex.Message}" +
|
||||
(cached is null ? " (formulaires d'origine utilisés)." : $" (révision {cached.Revision} en cache utilisée)."));
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var published = await AcceptAsync(bytes, ct).ConfigureAwait(false);
|
||||
if (published is null) return _loaded = fallback;
|
||||
if (cached is not null && published.Revision < cached.Revision)
|
||||
{
|
||||
Log.Error($"Formulaires des demandes révision {published.Revision} refusés : plus anciens que la révision {cached.Revision} " +
|
||||
"déjà vue (retour en arrière).");
|
||||
return _loaded = cached;
|
||||
}
|
||||
WriteCache(bytes);
|
||||
Log.Info($"Formulaires des demandes : révision {published.Revision}.");
|
||||
return _loaded = published;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fichier publié lisible, dans les règles et à la signature acceptée ; sinon null (raison au journal).</summary>
|
||||
private async Task<FeedbackFormsDocument?> AcceptAsync(byte[] bytes, CancellationToken ct)
|
||||
{
|
||||
FeedbackFormsDocument? document;
|
||||
try
|
||||
{
|
||||
document = FeedbackFormRules.Parse(bytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Log.Error($"Formulaires des demandes illisibles ({ex.Message}) : ignorés.");
|
||||
return null;
|
||||
}
|
||||
var errors = new List<string>();
|
||||
if (!FeedbackFormRules.Validate(document, errors))
|
||||
{
|
||||
Log.Error($"Formulaires des demandes refusés : {errors[0]}");
|
||||
return null;
|
||||
}
|
||||
if (_trust is not null && !await _trust.VerifyFeedbackFormsAsync(document!, ct).ConfigureAwait(false)) return null;
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>Dernière copie acceptée, revérifiée (règles et signature avec le trousseau en cache), sinon null.</summary>
|
||||
private FeedbackFormsDocument? ReadCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > FeedbackFormRules.MaxBytes) return null;
|
||||
var document = FeedbackFormRules.Parse(File.ReadAllBytes(CachePath));
|
||||
var errors = new List<string>();
|
||||
if (!FeedbackFormRules.Validate(document, errors)) return null;
|
||||
return _trust is null || _trust.VerifyCachedFeedbackForms(document!) ? document : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
Log.Warn($"Formulaires des demandes en cache illisibles : {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteCache(byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(CachePath)!);
|
||||
FileUtil.WriteAllTextAtomic(CachePath, System.Text.Encoding.UTF8.GetString(bytes));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Log.Warn($"Formulaires des demandes non gardés en cache : {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"/> ou <see cref="ContentKinds.Manifest"/>.</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)
|
||||
{
|
||||
@@ -108,7 +109,13 @@ public sealed class PlayerSigningCheck
|
||||
{
|
||||
var catalog = kind == ContentKinds.Catalog;
|
||||
var consequence = Mode == ContentSigningMode.Require
|
||||
? catalog
|
||||
? 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)."
|
||||
: "Les launchers des joueurs (signatures obligatoires) refuseront cette version : ni installation, ni mise à jour " +
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<ResourceDictionary Source="Themes/Controls.xaml" />
|
||||
<!-- Notes de version (format enrichi) : modèles partagés par la fenêtre principale et la fenêtre agrandie -->
|
||||
<ResourceDictionary Source="Themes/Changelog.xaml" />
|
||||
<!-- Champs des formulaires des demandes : fenêtre « Aide » et aperçu de la page du menu -->
|
||||
<ResourceDictionary Source="Themes/FeedbackForms.xaml" />
|
||||
<!-- Menu d'administration : styles et modèles des pages des modules (Views/Admin) -->
|
||||
<ResourceDictionary Source="Themes/Admin.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
@@ -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;
|
||||
@@ -59,8 +61,18 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private AdminWindowService? _admin;
|
||||
private CatalogService? _catalogService;
|
||||
private ContentTrustService? _trust;
|
||||
|
||||
/// <summary>Adresse du trousseau (même dossier que les formulaires publiés des demandes).</summary>
|
||||
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);
|
||||
|
||||
@@ -169,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).
|
||||
@@ -219,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)
|
||||
@@ -356,18 +374,35 @@ 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).
|
||||
var feedback = new FeedbackWindowService(window, apiUrl, version);
|
||||
// Formulaires publiés depuis le menu (feedback-forms.json, à côté du trousseau), vérifiés comme le catalogue.
|
||||
var formsUrl = GameLauncher.Modules.Feedback.FeedbackFormsService.PublishedUrl(_keyringUrl, ResolveCatalogUrl(catalog));
|
||||
var forms = _http is null ? null
|
||||
: 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;
|
||||
@@ -376,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>
|
||||
@@ -480,6 +562,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
private ContentTrustService? CreateContentTrust(LauncherCatalog catalog)
|
||||
{
|
||||
var (keyringUrl, mode, minSequence) = ResolveContentSigning(catalog);
|
||||
_keyringUrl = keyringUrl;
|
||||
if (mode == ContentSigningMode.Off) return null;
|
||||
var roots = LauncherUpdateService.LoadRootKeys();
|
||||
if (roots.Count == 0)
|
||||
@@ -583,6 +666,7 @@ public partial class App : Application, ILauncherRestartHost
|
||||
new ChangelogAdminModule(),
|
||||
new PublishAdminModule(),
|
||||
new FilesAdminModule(),
|
||||
new MaintenanceAdminModule(),
|
||||
new AccountsAdminModule(),
|
||||
new SigningAdminModule(),
|
||||
new AuditAdminModule()
|
||||
@@ -668,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
|
||||
|
||||
@@ -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.1.0</Version>
|
||||
<Version>3.3.1</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class AdminWindowService : IDisposable
|
||||
private readonly Uri? _apiUrl;
|
||||
private readonly string? _apiUrlError;
|
||||
private readonly PlayerSigningCheck? _playerSigning;
|
||||
private readonly Action? _maintenanceChanged;
|
||||
private LauncherApiClient? _client;
|
||||
private AdminWindow? _window;
|
||||
|
||||
@@ -39,9 +40,10 @@ public sealed class AdminWindowService : IDisposable
|
||||
/// <param name="playerSigning">
|
||||
/// Contrôle du trousseau des joueurs avant une publication signée (null : signatures non vérifiées, pas d'avertissement).
|
||||
/// </param>
|
||||
/// <param name="maintenanceChanged">Après une publication du mode maintenance : relecture immédiate par ce launcher.</param>
|
||||
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
|
||||
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes,
|
||||
PlayerSigningCheck? playerSigning = null)
|
||||
PlayerSigningCheck? playerSigning = null, Action? maintenanceChanged = null)
|
||||
{
|
||||
_owner = owner;
|
||||
_modules = modules;
|
||||
@@ -52,6 +54,7 @@ public sealed class AdminWindowService : IDisposable
|
||||
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
|
||||
_apiUrlError = apiUrlError;
|
||||
_playerSigning = playerSigning;
|
||||
_maintenanceChanged = maintenanceChanged;
|
||||
}
|
||||
|
||||
public bool IsAvailable => _apiUrl is not null;
|
||||
@@ -93,7 +96,8 @@ public sealed class AdminWindowService : IDisposable
|
||||
SettingsPath = _settingsPath,
|
||||
LauncherVersion = _launcherVersion,
|
||||
RootKeys = LauncherUpdateService.LoadRootKeys(),
|
||||
PlayerSigning = _playerSigning
|
||||
PlayerSigning = _playerSigning,
|
||||
MaintenanceChanged = _maintenanceChanged
|
||||
});
|
||||
window = new AdminWindow(shell);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
|
||||
@@ -10,19 +10,23 @@ namespace GameLauncher.Services;
|
||||
/// Bouton « Aide » de la fenêtre principale : ouvre le formulaire de demande pour le jeu sélectionné (fenêtre modale,
|
||||
/// le jeu ne peut donc pas changer pendant la saisie). L'adresse de l'API est celle du menu d'administration
|
||||
/// (« apiUrl » du launcher.json INTÉGRÉ en Release) : sans elle, le bouton n'est pas affiché.
|
||||
/// Le launcher ne contacte l'API qu'au moment de l'envoi, jamais en arrière-plan.
|
||||
/// Le launcher ne contacte l'API qu'au moment de l'envoi, jamais en arrière-plan ; les formulaires publiés (fichier
|
||||
/// statique signé, à côté du trousseau) ne sont lus qu'à l'ouverture de la fenêtre.
|
||||
/// </summary>
|
||||
public sealed class FeedbackWindowService
|
||||
{
|
||||
private readonly MainWindow _owner;
|
||||
private readonly Uri? _apiUrl;
|
||||
private readonly string _launcherVersion;
|
||||
private readonly IFeedbackFormsSource? _forms;
|
||||
private LauncherApiClient? _client;
|
||||
private FeedbackWindow? _window;
|
||||
|
||||
public FeedbackWindowService(MainWindow owner, string? apiUrl, string launcherVersion)
|
||||
/// <param name="forms">Formulaires publiés (null : formulaires d'origine).</param>
|
||||
public FeedbackWindowService(MainWindow owner, string? apiUrl, string launcherVersion, IFeedbackFormsSource? forms = null)
|
||||
{
|
||||
_owner = owner;
|
||||
_forms = forms;
|
||||
_apiUrl = apiUrl is not null && Uri.TryCreate(apiUrl, UriKind.Absolute, out var uri) ? uri : null;
|
||||
_launcherVersion = launcherVersion;
|
||||
}
|
||||
@@ -44,7 +48,7 @@ public sealed class FeedbackWindowService
|
||||
// Agent utilisateur générique (version du launcher seulement) : rien qui identifie le joueur ou son PC.
|
||||
_client ??= new LauncherApiClient(_apiUrl, $"GameLauncher/{_launcherVersion}");
|
||||
var viewModel = new FeedbackFormViewModel(new FeedbackTarget(gameId, gameName, installedVersion, _launcherVersion),
|
||||
new ApiFeedbackSender(_client), screenshots: new ScreenshotSource(() => _window));
|
||||
new ApiFeedbackSender(_client), screenshots: new ScreenshotSource(() => _window), forms: _forms);
|
||||
var window = new FeedbackWindow(viewModel);
|
||||
if (_owner.IsVisible) window.Owner = _owner;
|
||||
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
|
||||
@@ -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>
|
||||
@@ -2123,8 +2137,14 @@
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Width="30" Height="30" CornerRadius="9" VerticalAlignment="Top" Background="{DynamicResource BadgeBrush}">
|
||||
<Path x:Name="KindIcon" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
|
||||
Data="{Binding KindIconKey, Converter={StaticResource IconKey}}" />
|
||||
<Grid>
|
||||
<Path x:Name="KindIcon" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
|
||||
Data="{Binding KindIconKey, Converter={StaticResource IconKey}}"
|
||||
Visibility="{Binding HasKindCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<Image Width="20" Height="20" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
|
||||
Source="{Binding KindCustomIcon, Converter={StaticResource AdminBytesToImage}, ConverterParameter=64}"
|
||||
Visibility="{Binding HasKindCustomIcon, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="10,0,8,0">
|
||||
<TextBlock x:Name="ItemTitle" Text="{Binding Title}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
|
||||
@@ -2196,7 +2216,13 @@
|
||||
<StackPanel>
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Margin="0,0,14,0">
|
||||
<Path Style="{StaticResource AdminIconStyle}" Data="{Binding KindIconKey, Converter={StaticResource IconKey}}" />
|
||||
<Grid>
|
||||
<Path Style="{StaticResource AdminIconStyle}" Data="{Binding KindIconKey, Converter={StaticResource IconKey}}"
|
||||
Visibility="{Binding HasKindCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<Image Width="24" Height="24" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
|
||||
Source="{Binding KindCustomIcon, Converter={StaticResource AdminBytesToImage}, ConverterParameter=64}"
|
||||
Visibility="{Binding HasKindCustomIcon, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}" Width="32" Height="32" Padding="8" VerticalAlignment="Top"
|
||||
ToolTip="Copier la référence" ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}"
|
||||
@@ -2429,6 +2455,434 @@
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ Page « Formulaires des demandes » (feedback.forms) -->
|
||||
|
||||
<!-- Type de demande (liste de gauche) -->
|
||||
<Style x:Key="AdminFeedbackFormItemStyle" TargetType="ListBoxItem">
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="Bd" CornerRadius="12" Padding="12,10" BorderThickness="1"
|
||||
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Width="32" Height="32" CornerRadius="10" VerticalAlignment="Top" Background="{DynamicResource BadgeBrush}">
|
||||
<Grid>
|
||||
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
|
||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
||||
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<Image Width="22" Height="22" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
|
||||
Source="{Binding CustomIconBytes, Converter={StaticResource AdminBytesToImage}, ConverterParameter=64}"
|
||||
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="10,0,0,0">
|
||||
<TextBlock x:Name="ItemTitle" Text="{Binding Title}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
<TextBlock FontSize="11.5" Margin="0,2,0,0" TextTrimming="CharacterEllipsis" Foreground="{DynamicResource TextMutedBrush}">
|
||||
<Run Text="{Binding OriginalLabel, Mode=OneWay}" /><Run Text=" · " /><Run Text="{Binding FieldsText, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
<TextBlock x:Name="State" Text="{Binding StateText}" FontSize="11" FontWeight="SemiBold" Margin="0,3,0,0"
|
||||
Foreground="{DynamicResource Accent2Brush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
|
||||
</Trigger>
|
||||
<DataTrigger Binding="{Binding Hidden}" Value="True">
|
||||
<Setter TargetName="State" Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
|
||||
<Setter TargetName="ItemTitle" Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsDirty}" Value="True">
|
||||
<Setter TargetName="State" Property="Foreground" Value="{DynamicResource WarningBrush}" />
|
||||
</DataTrigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Ligne cliquable sans cadre (déplier / replier les réglages d'un champ) -->
|
||||
<Style x:Key="AdminPlainRowButtonStyle" TargetType="Button">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="Transparent" CornerRadius="8" Padding="4,2">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Bouton « Ajouter un champ » d'un type de champ -->
|
||||
<DataTemplate DataType="{x:Type afb:FeedbackFieldKindChoice}">
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Margin="0,0,8,8" Content="{Binding Label}"
|
||||
ui:IconAssist.Icon="{Binding IconKey, Converter={StaticResource IconKey}}" Command="{Binding AddCommand}" />
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Choix d'une liste ou d'une case à cocher -->
|
||||
<DataTemplate DataType="{x:Type afb:FeedbackOptionEditor}">
|
||||
<Grid Margin="0,0,0,6">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="60" ToolTip="{Binding Id}"
|
||||
Text="{Binding Label, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="6,0,0,0" VerticalAlignment="Center">
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="7" ToolTip="Monter"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" Command="{Binding MoveUpCommand}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="7" ToolTip="Descendre"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowDown}" Command="{Binding MoveDownCommand}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="7" ToolTip="Retirer ce choix"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding RemoveCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Champ d'un type de demande : ligne repliée (type, libellé, résumé) et réglages dépliés -->
|
||||
<DataTemplate DataType="{x:Type afb:FeedbackFieldEditor}">
|
||||
<Border CornerRadius="12" Padding="12,10" Margin="0,0,0,8" BorderThickness="1"
|
||||
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Width="28" Height="28" CornerRadius="8" VerticalAlignment="Center" Background="{DynamicResource BadgeBrush}">
|
||||
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
|
||||
Data="{Binding KindIconKey, Converter={StaticResource IconKey}}" />
|
||||
</Border>
|
||||
<Button Grid.Column="1" Style="{StaticResource AdminPlainRowButtonStyle}" Margin="6,0,8,0" HorizontalAlignment="Stretch"
|
||||
Command="{Binding ToggleCommand}" ToolTip="Afficher / masquer les réglages">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" />
|
||||
<TextBlock Text="{Binding Summary}" FontSize="11.5" Margin="0,2,0,0" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" ToolTip="Monter"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" Command="{Binding MoveUpCommand}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" ToolTip="Descendre"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowDown}" Command="{Binding MoveDownCommand}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
|
||||
ToolTip="Retirer ce champ (les demandes déjà reçues le gardent)"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding RemoveCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Margin="38,12,0,2" Visibility="{Binding IsExpanded, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Libellé" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="80"
|
||||
Text="{Binding Label, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
|
||||
<TextBlock Text="Aide (texte indicatif ou sous le champ, 4 lignes au plus, sans lien)" Style="{StaticResource AdminFieldLabelStyle}"
|
||||
Margin="0,10,0,6" />
|
||||
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="56" MaxHeight="110" MaxLength="200"
|
||||
Text="{Binding Hint, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
|
||||
|
||||
<WrapPanel Margin="0,12,0,0">
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding Required, Mode=TwoWay}" Margin="0,0,24,8">
|
||||
<TextBlock Text="{Binding RequiredText}" FontSize="12.5" />
|
||||
</CheckBox>
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding ShowInList, Mode=TwoWay}" Margin="0,0,24,8"
|
||||
Visibility="{Binding CanShowInList, Converter={StaticResource BoolToVisibility}}"
|
||||
ToolTip="La valeur choisie s'affiche dans la liste « Demandes des joueurs » (un seul champ par type)">
|
||||
<TextBlock Text="Aperçu dans la liste des demandes" FontSize="12.5" />
|
||||
</CheckBox>
|
||||
</WrapPanel>
|
||||
|
||||
<StackPanel Visibility="{Binding HasMaxLength, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="{Binding MaxLengthText}" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,4,0,6" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Width="140" HorizontalAlignment="Left" MaxLength="4"
|
||||
Text="{Binding MaxLength, UpdateSourceTrigger=PropertyChanged, Delay=400}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Visibility="{Binding IsRating, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Nombre d'étoiles" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,4,0,6" />
|
||||
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="180" HorizontalAlignment="Left"
|
||||
ItemsSource="{Binding RatingSizes}" SelectedItem="{Binding RatingMax, Mode=TwoWay}" DisplayMemberPath="Label" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Visibility="{Binding HasOptions, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Choix (2 à 20, dans l'ordre affiché aux joueurs)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,4,0,6" />
|
||||
<ItemsControl ItemsSource="{Binding Options}" Focusable="False" />
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Ajouter un choix" HorizontalAlignment="Left" Margin="0,2,0,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding AddOptionCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" FontSize="11.5" Margin="0,10,0,0" TextWrapping="Wrap">
|
||||
<Run Text="Identifiant :" /><Run Text=" " /><Run Text="{Binding Id, Mode=OneWay}" />
|
||||
<Run Text="(fixe : il relie les réponses des joueurs à ce champ ; pour changer de type, ajoutez un autre champ)" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Réglages d'un type de demande (colonne de droite) -->
|
||||
<DataTemplate DataType="{x:Type afb:FeedbackFormEditor}">
|
||||
<StackPanel>
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<StackPanel>
|
||||
<DockPanel Margin="0,0,0,12">
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding Hidden, Mode=TwoWay}" Margin="0,0,14,0"
|
||||
VerticalAlignment="Center" ToolTip="Un type masqué n'est plus proposé aux joueurs ; ses demandes restent consultables">
|
||||
<TextBlock Text="Masqué" FontSize="12.5" />
|
||||
</CheckBox>
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" ToolTip="Monter (ordre affiché aux joueurs)"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" Command="{Binding MoveUpCommand}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" ToolTip="Descendre"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.ArrowDown}" Command="{Binding MoveDownCommand}" />
|
||||
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
|
||||
ToolTip="Supprimer ce type (les demandes déjà reçues restent consultables)"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding RemoveCommand}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding OriginalLabel, StringFormat='Type de demande · {0}'}" Style="{StaticResource AdminCardTitleStyle}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</DockPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="200" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="Nom affiché aux joueurs" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="40"
|
||||
Text="{Binding Label, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Margin="10,0,0,0">
|
||||
<TextBlock Text="Icône d'origine" Style="{StaticResource AdminFieldLabelStyle}" />
|
||||
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Icons}" SelectedItem="{Binding Icon, Mode=TwoWay}"
|
||||
IsEnabled="{Binding UsesDefaultIcon}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Path Width="14" Height="14" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" VerticalAlignment="Center"
|
||||
Data="{Binding Value, Converter={StaticResource IconKey}}" />
|
||||
<TextBlock Text="{Binding Label}" Margin="8,0,0,0" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<!-- Icône importée : fichier .ico obligatoire, image 64 × 64 convertie en PNG sans métadonnées -->
|
||||
<DockPanel Margin="0,10,0,0">
|
||||
<Border DockPanel.Dock="Left" Width="44" Height="44" CornerRadius="12" Background="{DynamicResource BadgeBrush}" Margin="0,0,12,0">
|
||||
<Grid>
|
||||
<Path Width="20" Height="20" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
|
||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
||||
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<Image Width="32" Height="32" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
|
||||
Source="{Binding CustomIconBytes, Converter={StaticResource AdminBytesToImage}, ConverterParameter=64}"
|
||||
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Importer un fichier .ico…" Margin="0,0,8,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}" Command="{Binding ImportIconCommand}" />
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Retirer l'icône importée"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding RemoveIconCommand}"
|
||||
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" FontSize="11.5" VerticalAlignment="Center" TextWrapping="Wrap"
|
||||
Text="Ou une icône à vous : fichier .ico uniquement, contenant une image de 64 × 64 pixels (PNG ou couleurs 24/32 bits). Elle est convertie en PNG sans métadonnées et signée avec les formulaires." />
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Text="Description (sous le nom, dans le choix du type)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,10,0,6" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="120"
|
||||
Text="{Binding Description, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
|
||||
<TextBlock Text="Exemple de titre (texte indicatif du champ « Titre »)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,10,0,6" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="80"
|
||||
Text="{Binding TitleHint, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding TechByDefault, Mode=TwoWay}" Margin="0,12,0,0">
|
||||
<TextBlock Text="« Joindre les informations techniques » coché par défaut" FontSize="12.5" />
|
||||
</CheckBox>
|
||||
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding AllowAttachments, Mode=TwoWay}" Margin="0,10,0,0"
|
||||
ToolTip="3 captures au plus : vraie image exigée, PNG ou JPEG, sans métadonnées (contrôlées par le launcher et le serveur)">
|
||||
<TextBlock Text="Captures d'écran autorisées (3 au plus)" FontSize="12.5" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource AdminCardStyle}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource AdminCardTitleStyle}" Margin="0,0,0,4">
|
||||
<Run Text="Champs" /><Run Text=" · " /><Run Text="{Binding FieldsText, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" FontSize="12" Margin="0,0,0,12" TextWrapping="Wrap"
|
||||
Text="Le titre de la demande, les captures d'écran et les informations techniques sont toujours proposés. Cliquez sur un champ pour le régler ; 15 champs au plus." />
|
||||
<ItemsControl ItemsSource="{Binding Fields}" Focusable="False" />
|
||||
<TextBlock Text="Ajouter un champ" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,8,0,8" />
|
||||
<ItemsControl ItemsSource="{Binding FieldKinds}" Focusable="False" IsEnabled="{Binding AddFieldsEnabled}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type afb:FeedbackFormsPanelViewModel}">
|
||||
<!-- Deux colonnes qui défilent séparément (HasSplitLayout) : types à gauche, réglages et aperçu à droite. -->
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
|
||||
<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 WarningBrush}"
|
||||
Visibility="{Binding HasConflict, Converter={StaticResource BoolToVisibility}}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Recharger" Margin="12,0,0,0"
|
||||
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}" />
|
||||
<TextBlock TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center"
|
||||
Text="Les formulaires ont été publiés par un autre administrateur entre-temps : rechargez-les, puis refaites vos modifications." />
|
||||
</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 non configurée sur le serveur : les formulaires sont écrits à côté du trousseau public (LauncherApi:Catalog:PublicFile ou LauncherApi:Keyring:PublicFile). Voir server/deploy/LISEZMOI.md, étape 3 nonies." />
|
||||
</Border>
|
||||
|
||||
<!-- État publié et actions -->
|
||||
<Border Style="{StaticResource AdminCardStyle}" Padding="16,14">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Revenir à l'origine" Margin="0,0,8,0"
|
||||
ToolTip="Noms et champs d'origine des quatre types (à publier ensuite)"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Restore}" Command="{Binding RestoreOriginalCommand}" />
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Annuler les modifications" Margin="0,0,8,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding DiscardCommand}" />
|
||||
<Button Style="{StaticResource SmallButtonStyle}" Content="Recharger" Margin="0,0,8,0"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}" />
|
||||
<Button Style="{StaticResource AccentButtonStyle}" Content="Publier"
|
||||
ui:IconAssist.Icon="{StaticResource Icon.CloudUpload}" Command="{Binding PublishCommand}" />
|
||||
</StackPanel>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding PublishedText}" FontSize="12.5" FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Style="{StaticResource AdminHintStyle}" FontSize="11.5" Margin="0,3,0,0" TextWrapping="Wrap"
|
||||
Text="Communs à tous les jeux. Signés avec la clé de ce PC (autorisation « formulaires » du trousseau) ; les launchers les vérifient avant de les afficher." />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Contrôles en direct -->
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource DangerBrush}"
|
||||
Visibility="{Binding HasValidationErrors, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="À corriger avant de publier :" FontSize="12.5" FontWeight="SemiBold" Margin="0,0,0,4" />
|
||||
<ItemsControl ItemsSource="{Binding ValidationErrors}" Focusable="False">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding StringFormat='• {0}'}" FontSize="12" TextWrapping="Wrap" Margin="0,1,0,0" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
|
||||
Visibility="{Binding HasWarnings, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl ItemsSource="{Binding Warnings}" Focusable="False">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0,1,0,1">
|
||||
<Path DockPanel.Dock="Left" Width="14" Height="14" Stretch="Uniform" Fill="{DynamicResource WarningBrush}"
|
||||
Data="{StaticResource Icon.Alert}" VerticalAlignment="Top" Margin="0,1,8,0" />
|
||||
<TextBlock Text="{Binding}" FontSize="12" TextWrapping="Wrap" />
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="260" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="420" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="{Binding FormsText}" Style="{StaticResource SectionTitleStyle}" Margin="2,0,0,8" />
|
||||
<Button DockPanel.Dock="Bottom" Style="{StaticResource SmallButtonStyle}" Content="Ajouter un type de demande" Margin="0,4,8,0"
|
||||
HorizontalAlignment="Left" ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding AddFormCommand}" />
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,8,0">
|
||||
<ListBox ItemsSource="{Binding Forms}" SelectedItem="{Binding SelectedForm, Mode=TwoWay}"
|
||||
Style="{StaticResource AdminListBoxStyle}" IsEnabled="{Binding IsEditable}"
|
||||
ItemContainerStyle="{StaticResource AdminFeedbackFormItemStyle}" />
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Réglages du type sélectionné (modèle FeedbackFormEditor ci-dessus) -->
|
||||
<ScrollViewer Grid.Column="1" Margin="10,0,0,0" VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,10,0"
|
||||
ui:ScrollAssist.ResetOn="{Binding SelectedForm}">
|
||||
<ContentControl Content="{Binding SelectedForm}" Focusable="False" IsEnabled="{Binding IsEditable}" />
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Aperçu : mêmes modèles que la fenêtre « Aide » (Themes/FeedbackForms.xaml) ; rien n'est envoyé -->
|
||||
<ScrollViewer Grid.Column="2" Margin="10,0,0,0" VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0">
|
||||
<Border CornerRadius="18" Padding="20,18" BorderThickness="1" Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource BorderBrush}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding PreviewTitle}" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding PreviewNote}" Style="{StaticResource AdminHintStyle}" FontSize="11.5" Margin="0,0,0,14" TextWrapping="Wrap" />
|
||||
<TextBlock Text="Titre *" Style="{StaticResource FeedbackLabelStyle}" />
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" Margin="0,0,0,14" ui:TextAssist.Placeholder="{Binding PreviewTitleHint}" />
|
||||
<ItemsControl ItemsSource="{Binding Preview}" Focusable="False" />
|
||||
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" IsChecked="{Binding PreviewTechByDefault, Mode=OneWay}" IsHitTestVisible="False">
|
||||
<TextBlock Text="Joindre les informations techniques" TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- ============================================================ Page « Signature » (phase 7) -->
|
||||
|
||||
<!-- Clé du trousseau proposé -->
|
||||
@@ -2551,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">
|
||||
@@ -2574,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}">
|
||||
@@ -2886,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}" />
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:GameLauncher.Controls"
|
||||
xmlns:fb="clr-namespace:GameLauncher.Modules.Feedback;assembly=GameLauncher.Modules.Feedback">
|
||||
|
||||
<!--
|
||||
Champs des formulaires des demandes (bouton « Aide ») : zone de texte, texte court, liste déroulante, cases à
|
||||
cocher, case oui/non, note en étoiles. Modèles PARTAGÉS par la fenêtre « Aide » des joueurs (Views/FeedbackWindow)
|
||||
et l'aperçu de la page « Formulaires des demandes » du menu d'administration (Themes/Admin.xaml) : l'aperçu
|
||||
montre exactement ce que verront les joueurs. ViewModels : GameLauncher.Modules.Feedback.
|
||||
-->
|
||||
|
||||
<!-- Étoile d'une note : bouton sans cadre -->
|
||||
<Style x:Key="FeedbackStarButtonStyle" TargetType="Button">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Padding" Value="3" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="Transparent" CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FeedbackLabelStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FeedbackFieldErrorStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Margin" Value="2,5,0,0" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Case à cocher arrondie (cases à cocher et case oui/non des formulaires) -->
|
||||
<Style x:Key="FeedbackCheckBoxStyle" TargetType="CheckBox">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Margin" Value="0,3" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="CheckBox">
|
||||
<Grid Background="Transparent">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border x:Name="Box" Width="20" Height="20" CornerRadius="6" BorderThickness="1.5" VerticalAlignment="Top"
|
||||
Margin="0,1,0,0" Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderStrongBrush}">
|
||||
<Path x:Name="Mark" Width="11" Height="11" Stretch="Uniform" Data="{StaticResource Icon.Check}"
|
||||
Fill="{DynamicResource OnAccentBrush}" Visibility="Collapsed" />
|
||||
</Border>
|
||||
<ContentPresenter Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center" RecognizesAccessKey="False" />
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Box" Property="Background" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
<Setter TargetName="Mark" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsKeyboardFocused" Value="True">
|
||||
<Setter TargetName="Box" Property="BorderThickness" Value="2.5" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<DataTemplate DataType="{x:Type fb:FeedbackCheckOption}">
|
||||
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" IsChecked="{Binding IsChecked}">
|
||||
<TextBlock Text="{Binding Label}" TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type fb:RatingStar}">
|
||||
<Button Style="{StaticResource FeedbackStarButtonStyle}" Command="{Binding SelectCommand}" ToolTip="{Binding ToolTip}">
|
||||
<Path x:Name="StarIcon" Width="26" Height="26" Stretch="Uniform" Data="{StaticResource Icon.Star}"
|
||||
Fill="{DynamicResource BorderStrongBrush}" />
|
||||
</Button>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsOn}" Value="True">
|
||||
<Setter TargetName="StarIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!--
|
||||
Champ du formulaire (une seule partie visible selon son type) : zone de texte, texte court, liste déroulante,
|
||||
cases à cocher, case oui/non ou note en étoiles (3, 5 ou 10).
|
||||
-->
|
||||
<DataTemplate DataType="{x:Type fb:FeedbackFieldViewModel}">
|
||||
<StackPanel Margin="0,0,0,14">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource FeedbackLabelStyle}" TextWrapping="Wrap"
|
||||
Visibility="{Binding ShowLabel, Converter={StaticResource BoolToVisibility}}" />
|
||||
|
||||
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding MaxLength}" SpellCheck.IsEnabled="True"
|
||||
ui:TextAssist.Placeholder="{Binding Hint}" Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}"
|
||||
Visibility="{Binding IsText, Converter={StaticResource BoolToVisibility}}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding CheckOptions}" Focusable="False"
|
||||
Visibility="{Binding IsCheckboxes, Converter={StaticResource BoolToVisibility}}" />
|
||||
|
||||
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" IsChecked="{Binding IsChecked}"
|
||||
Visibility="{Binding IsYesNo, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="{Binding Label}" TextWrapping="Wrap" FontWeight="SemiBold" />
|
||||
</CheckBox>
|
||||
|
||||
<Grid Visibility="{Binding IsMultiline, Converter={StaticResource BoolToVisibility}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="84" MaxHeight="180"
|
||||
MaxLength="{Binding MaxLength}" SpellCheck.IsEnabled="True"
|
||||
ui:TextAssist.Placeholder="{Binding Hint}"
|
||||
Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock Grid.Row="1" Text="{Binding LengthText}" FontSize="11" Margin="0,4,4,0" HorizontalAlignment="Right"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</Grid>
|
||||
|
||||
<ComboBox Style="{StaticResource ModernComboBoxStyle}" MaxWidth="380" HorizontalAlignment="Left" MinWidth="260"
|
||||
ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption, Mode=TwoWay}" DisplayMemberPath="Label"
|
||||
Visibility="{Binding IsChoice, Converter={StaticResource BoolToVisibility}}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Visibility="{Binding IsRating, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl ItemsSource="{Binding Stars}" Focusable="False" Margin="-3,0,0,0">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" MaxWidth="400" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="{Binding RatingText}" FontSize="12.5" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Aide des champs sans zone de saisie (dans une zone de texte, elle sert de texte indicatif) -->
|
||||
<TextBlock Text="{Binding Hint}" FontSize="12" Margin="2,4,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}"
|
||||
Visibility="{Binding ShowHintText, Converter={StaticResource BoolToVisibility}}" />
|
||||
|
||||
<TextBlock Text="{Binding Error}" Style="{StaticResource FeedbackFieldErrorStyle}"
|
||||
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ResourceDictionary>
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -62,91 +62,10 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Étoile d'une note : bouton sans cadre -->
|
||||
<Style x:Key="FeedbackStarButtonStyle" TargetType="Button">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
|
||||
<Setter Property="Padding" Value="3" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="Transparent" CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FeedbackLabelStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style x:Key="FeedbackFieldErrorStyle" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Margin" Value="2,5,0,0" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}" />
|
||||
</Style>
|
||||
|
||||
<DataTemplate DataType="{x:Type fb:RatingStar}">
|
||||
<Button Style="{StaticResource FeedbackStarButtonStyle}" Command="{Binding SelectCommand}" ToolTip="{Binding ToolTip}">
|
||||
<Path x:Name="StarIcon" Width="26" Height="26" Stretch="Uniform" Data="{StaticResource Icon.Star}"
|
||||
Fill="{DynamicResource BorderStrongBrush}" />
|
||||
</Button>
|
||||
<DataTemplate.Triggers>
|
||||
<DataTrigger Binding="{Binding IsOn}" Value="True">
|
||||
<Setter TargetName="StarIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
|
||||
</DataTrigger>
|
||||
</DataTemplate.Triggers>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Champ du formulaire : texte, liste de choix ou note (une seule partie visible) -->
|
||||
<DataTemplate DataType="{x:Type fb:FeedbackFieldViewModel}">
|
||||
<StackPanel Margin="0,0,0,14">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource FeedbackLabelStyle}" />
|
||||
|
||||
<Grid Visibility="{Binding IsMultiline, Converter={StaticResource BoolToVisibility}}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="84" MaxHeight="180"
|
||||
MaxLength="{Binding MaxLength}" SpellCheck.IsEnabled="True"
|
||||
ui:TextAssist.Placeholder="{Binding Hint}"
|
||||
Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" />
|
||||
<TextBlock Grid.Row="1" Text="{Binding LengthText}" FontSize="11" Margin="0,4,4,0" HorizontalAlignment="Right"
|
||||
Foreground="{DynamicResource TextMutedBrush}" />
|
||||
</Grid>
|
||||
|
||||
<ComboBox Style="{StaticResource ModernComboBoxStyle}" MaxWidth="380" HorizontalAlignment="Left" MinWidth="260"
|
||||
ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption, Mode=TwoWay}" DisplayMemberPath="Label"
|
||||
Visibility="{Binding IsChoice, Converter={StaticResource BoolToVisibility}}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Visibility="{Binding IsRating, Converter={StaticResource BoolToVisibility}}">
|
||||
<ItemsControl ItemsSource="{Binding Stars}" Focusable="False" Margin="-3,0,0,0">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
<TextBlock Text="{Binding RatingText}" FontSize="12.5" Margin="10,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="{Binding Error}" Style="{StaticResource FeedbackFieldErrorStyle}"
|
||||
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<!--
|
||||
Champs des formulaires (zone de texte, texte court, liste, cases à cocher, case oui/non, étoiles) : modèles
|
||||
partagés avec l'aperçu de la page « Formulaires des demandes » du menu, dans Themes/FeedbackForms.xaml.
|
||||
-->
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="20">
|
||||
@@ -167,8 +86,15 @@
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Border Width="48" Height="48" CornerRadius="14" Background="{DynamicResource AccentGradientBrush}">
|
||||
<Path Data="{Binding FormIconKey, Converter={StaticResource IconKey}}" Fill="{DynamicResource OnAccentBrush}"
|
||||
Width="22" Height="22" Stretch="Uniform" />
|
||||
<Grid>
|
||||
<Path Data="{Binding FormIconKey, Converter={StaticResource IconKey}}" Fill="{DynamicResource OnAccentBrush}"
|
||||
Width="22" Height="22" Stretch="Uniform"
|
||||
Visibility="{Binding HasFormCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<!-- Icône importée depuis le menu (PNG 64 × 64 vérifié avec les formulaires signés) -->
|
||||
<Image Width="30" Height="30" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
|
||||
Source="{Binding FormCustomIcon, Converter={StaticResource FeedbackBytesToImage}, ConverterParameter=64}"
|
||||
Visibility="{Binding HasFormCustomIcon, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding FormTitle}" FontSize="19" FontWeight="SemiBold" />
|
||||
@@ -186,6 +112,14 @@
|
||||
<!-- ============================================ Étape 1 : type de demande -->
|
||||
<StackPanel Visibility="{Binding IsChoosingKind, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Que souhaitez-vous nous transmettre ?" FontSize="14" FontWeight="SemiBold" Margin="0,0,0,12" />
|
||||
<!-- Lecture des formulaires publiés (quelques secondes au plus, sinon formulaires d'origine) -->
|
||||
<StackPanel Orientation="Horizontal" Margin="2,4,0,16"
|
||||
Visibility="{Binding IsLoadingForms, Converter={StaticResource BoolToVisibility}}">
|
||||
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="90" Height="4" IsIndeterminate="True"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Chargement des formulaires…" FontSize="12.5" Margin="12,0,0,0" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<ItemsControl ItemsSource="{Binding Kinds}" Focusable="False" Margin="0,0,-12,0">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -201,8 +135,14 @@
|
||||
Data="{StaticResource Icon.ChevronRight}" />
|
||||
<Border DockPanel.Dock="Left" Width="40" Height="40" CornerRadius="12" Margin="0,0,14,0"
|
||||
VerticalAlignment="Center" Background="{DynamicResource BadgeBrush}">
|
||||
<Path Width="19" Height="19" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
|
||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}" />
|
||||
<Grid>
|
||||
<Path Width="19" Height="19" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
|
||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
||||
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
|
||||
<Image Width="26" Height="26" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
|
||||
Source="{Binding CustomIcon, Converter={StaticResource FeedbackBytesToImage}, ConverterParameter=64}"
|
||||
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Label}" FontSize="14" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
||||
@@ -250,7 +190,7 @@
|
||||
<ItemsControl ItemsSource="{Binding Fields}" Focusable="False" />
|
||||
|
||||
<!-- Captures d'écran : converties et nettoyées de leurs métadonnées avant l'envoi -->
|
||||
<StackPanel Margin="0,0,0,14" Visibility="{Binding ScreenshotsAvailable, Converter={StaticResource BoolToVisibility}}">
|
||||
<StackPanel Margin="0,0,0,14" Visibility="{Binding AttachmentsAllowed, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="Captures d'écran (facultatif)" Style="{StaticResource FeedbackLabelStyle}" />
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Ajouter une image…" Margin="0,0,10,0"
|
||||
|
||||
@@ -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,217 @@
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Modules;
|
||||
using GameLauncher.Modules.Admin;
|
||||
using GameLauncher.Modules.Admin.Feedback;
|
||||
using GameLauncher.Modules.Admin.ViewModels;
|
||||
using GameLauncher.Tests.Contracts;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
|
||||
namespace GameLauncher.Tests.Admin;
|
||||
|
||||
/// <summary>Page « Formulaires des demandes » du menu : modification, contrôles en direct, aperçu, publication signée.</summary>
|
||||
public class FeedbackFormsPanelTests
|
||||
{
|
||||
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new FeedbackAdminModule() });
|
||||
|
||||
private static async Task<(TestApi Api, string PublicDirectory)> StartApiAsync()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-forms-panel", 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"),
|
||||
["LauncherApi:Feedback:Enabled"] = "true",
|
||||
["LauncherApi:Feedback:Games:0"] = "EZR"
|
||||
});
|
||||
return (api, directory);
|
||||
}
|
||||
|
||||
private static async Task<(AdminShellViewModel Shell, FeedbackFormsPanelViewModel Page)> OpenAsync(TestApi api, string folder, FakeDialogs dialogs)
|
||||
{
|
||||
var (_, signer) = AdminTestKit.CreateKeyFile(folder);
|
||||
AdminTestKit.RegisterOwner(api, signer);
|
||||
signer.Dispose();
|
||||
var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder, dialogs, Modules));
|
||||
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
|
||||
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
|
||||
workspace.Navigate(FeedbackFormsPanelViewModel.PanelId);
|
||||
await workspace.Activation;
|
||||
return (shell, Assert.IsType<FeedbackFormsPanelViewModel>(workspace.SelectedPanel!.ViewModel));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forms_are_edited_checked_previewed_and_published_signed()
|
||||
{
|
||||
var (api, directory) = await StartApiAsync();
|
||||
await using var _ = api;
|
||||
using var folder = new TempFolder();
|
||||
var dialogs = new FakeDialogs();
|
||||
var (shell, page) = await OpenAsync(api, folder.Path, dialogs);
|
||||
await using var __ = shell;
|
||||
|
||||
// Formulaires d'origine : rien de modifié, rien à publier.
|
||||
Assert.Equal(4, page.Forms.Count);
|
||||
Assert.Contains("Formulaires d'origine", page.PublishedText);
|
||||
Assert.False(page.IsDirty);
|
||||
Assert.Empty(page.ValidationErrors);
|
||||
Assert.False(page.PublishCommand.CanExecute(null));
|
||||
Assert.All(page.Forms, f => Assert.False(f.IsDirty));
|
||||
|
||||
// Bug : renommé, un champ retiré, des cases à cocher et une case oui/non ajoutées ; l'aperçu suit.
|
||||
var bug = page.Forms.Single(f => f.Kind == FeedbackKinds.Bug);
|
||||
page.SelectedForm = bug;
|
||||
bug.Label = "Problème technique";
|
||||
bug.Fields.Single(f => f.Id == "expected").RemoveCommand.Execute(null);
|
||||
bug.FieldKinds.Single(k => k.Kind == FeedbackFieldKind.Checkboxes).AddCommand.Execute(null);
|
||||
var where = bug.Fields.Last();
|
||||
Assert.True(where.IsExpanded && where.IsNew && where.HasOptions);
|
||||
where.Label = "Où cela arrive-t-il ?";
|
||||
where.Required = true;
|
||||
where.AddOptionCommand.Execute(null);
|
||||
where.Options[0].Label = "Partie solo";
|
||||
where.Options[1].Label = "Coopératif";
|
||||
where.Options[2].Label = "Menus";
|
||||
where.MoveUpCommand.Execute(null);
|
||||
bug.FieldKinds.Single(k => k.Kind == FeedbackFieldKind.YesNo).AddCommand.Execute(null);
|
||||
bug.Fields.Last().Label = "Le problème revient après un redémarrage";
|
||||
Assert.NotEqual(where.Id, bug.Fields.Last().Id);
|
||||
Assert.True(page.IsDirty);
|
||||
Assert.True(bug.IsDirty);
|
||||
Assert.False(page.Forms.Single(f => f.Kind == FeedbackKinds.Review).IsDirty);
|
||||
Assert.Equal(bug.Fields.Count, page.Preview.Count);
|
||||
Assert.Equal(3, page.Preview.Single(p => p.Id == where.Id).CheckOptions.Count);
|
||||
Assert.True(page.Preview.Last().IsYesNo);
|
||||
|
||||
// Avis : note sur 10 ; « autre » masqué.
|
||||
var review = page.Forms.Single(f => f.Kind == FeedbackKinds.Review);
|
||||
var rating = review.Fields.Single(f => f.Id == "rating");
|
||||
rating.RatingMax = rating.RatingSizes.Single(c => c.Value == "10");
|
||||
page.Forms.Single(f => f.Kind == FeedbackKinds.Other).Hidden = true;
|
||||
|
||||
// Contrôles en direct : un lien bloque la publication, une donnée personnelle avertit seulement.
|
||||
bug.Description = "Plus d'infos sur www.exemple.com";
|
||||
Assert.Contains(page.ValidationErrors, e => e.Contains("liens"));
|
||||
Assert.False(page.PublishCommand.CanExecute(null));
|
||||
bug.Description = "Un problème technique dans le jeu.";
|
||||
bug.Fields.Last().Hint = "Ou laissez votre adresse e-mail";
|
||||
Assert.Empty(page.ValidationErrors);
|
||||
Assert.Contains(page.Warnings, w => w.Contains("e-mail"));
|
||||
bug.Fields.Last().Hint = "";
|
||||
Assert.DoesNotContain(page.Warnings, w => w.Contains("e-mail"));
|
||||
Assert.True(page.PublishCommand.CanExecute(null));
|
||||
|
||||
// Publication signée.
|
||||
await page.PublishCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
Assert.Contains("révision 1", page.StatusMessage);
|
||||
Assert.False(page.IsDirty);
|
||||
Assert.Contains("Révision 1 publiée", page.PublishedText);
|
||||
var published = FeedbackFormRules.Parse(File.ReadAllBytes(Path.Combine(directory, "feedback-forms.json")))!;
|
||||
Assert.Equal("Problème technique", published.Find(FeedbackKinds.Bug)!.Label);
|
||||
Assert.True(published.Find(FeedbackKinds.Other)!.Hidden);
|
||||
Assert.Equal(10, published.Find(FeedbackKinds.Review)!.Field("rating")!.RatingMax);
|
||||
Assert.Equal(new[] { "Partie solo", "Coopératif", "Menus" },
|
||||
published.Find(FeedbackKinds.Bug)!.Fields.Single(f => f.Kind == FeedbackFieldKind.Checkboxes).Choices.Select(o => o.Label).ToArray());
|
||||
|
||||
// Annuler, revenir à l'origine (non publié tant qu'on ne publie pas).
|
||||
page.Forms.Single(f => f.Kind == FeedbackKinds.Feature).Label = "Idée";
|
||||
Assert.True(page.IsDirty);
|
||||
page.DiscardCommand.Execute(null);
|
||||
Assert.False(page.IsDirty);
|
||||
page.RestoreOriginalCommand.Execute(null);
|
||||
Assert.True(page.IsDirty);
|
||||
Assert.Equal("Bug", page.Forms.Single(f => f.Kind == FeedbackKinds.Bug).Label);
|
||||
Assert.False(page.Forms.Single(f => f.Kind == FeedbackKinds.Other).Hidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_publication_by_someone_else_in_between_is_never_overwritten()
|
||||
{
|
||||
var (api, _) = await StartApiAsync();
|
||||
await using var _ = api;
|
||||
using var folder = new TempFolder();
|
||||
var (shell, page) = await OpenAsync(api, folder.Path, new FakeDialogs());
|
||||
await using var __ = shell;
|
||||
|
||||
// Un autre administrateur publie pendant la modification.
|
||||
using var otherKey = api.AddOwner();
|
||||
await using (var other = await api.LoginAsync(otherKey))
|
||||
await other.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom(), 0);
|
||||
|
||||
page.Forms.Single(f => f.Kind == FeedbackKinds.Feature).Label = "Idée";
|
||||
await page.PublishCommand.ExecuteAsync();
|
||||
Assert.True(page.HasConflict);
|
||||
Assert.True(page.HasError);
|
||||
Assert.False(page.PublishCommand.CanExecute(null));
|
||||
|
||||
await page.ReloadCommand.ExecuteAsync(); // modifications abandonnées (confirmation acceptée)
|
||||
Assert.False(page.HasConflict);
|
||||
Assert.Equal(FeedbackFormsKit.NewBugLabel, page.Forms.Single(f => f.Kind == FeedbackKinds.Bug).Label);
|
||||
Assert.False(page.IsDirty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Categories_are_added_reordered_removed_and_get_an_icon_only_from_a_64_pixel_ico()
|
||||
{
|
||||
var (api, directory) = await StartApiAsync();
|
||||
await using var _ = api;
|
||||
using var folder = new TempFolder();
|
||||
var dialogs = new FakeDialogs();
|
||||
var (shell, page) = await OpenAsync(api, folder.Path, dialogs);
|
||||
await using var __ = shell;
|
||||
|
||||
// Ajout : nouveau type sélectionné, avec un champ « Message », placé à la fin.
|
||||
page.AddFormCommand.Execute(null);
|
||||
var added = page.SelectedForm!;
|
||||
Assert.True(added.IsNew);
|
||||
Assert.Equal("type_1", added.Kind);
|
||||
Assert.Equal(5, page.Forms.Count);
|
||||
Assert.Same(added, page.Forms[^1]);
|
||||
added.Label = "Suggestion de niveau";
|
||||
added.AllowAttachments = false;
|
||||
Assert.Contains("non proposées", page.PreviewNote);
|
||||
|
||||
// Icône : seul un .ico avec une image 64 × 64 est accepté.
|
||||
var wrong = Path.Combine(folder.Path, "icone.png");
|
||||
File.WriteAllBytes(wrong, FeedbackTestImages.PngWith("tEXt", "x"));
|
||||
dialogs.NextOpenFile = wrong;
|
||||
added.ImportIconCommand.Execute(null);
|
||||
Assert.False(added.HasCustomIcon);
|
||||
Assert.Contains(dialogs.Messages, m => m.Contains("Seuls les fichiers .ico"));
|
||||
var small = Path.Combine(folder.Path, "petite.ico");
|
||||
File.WriteAllBytes(small, FeedbackFormsKit.Ico(32));
|
||||
dialogs.NextOpenFile = small;
|
||||
added.ImportIconCommand.Execute(null);
|
||||
Assert.False(added.HasCustomIcon);
|
||||
Assert.Contains(dialogs.Messages, m => m.Contains("64 × 64"));
|
||||
var good = Path.Combine(folder.Path, "niveau.ico");
|
||||
File.WriteAllBytes(good, FeedbackFormsKit.Ico());
|
||||
dialogs.NextOpenFile = good;
|
||||
added.ImportIconCommand.Execute(null);
|
||||
Assert.True(added.HasCustomIcon);
|
||||
Assert.False(added.UsesDefaultIcon);
|
||||
Assert.Null(FeedbackIcons.CheckCustom(added.CustomIcon));
|
||||
|
||||
// Ordre : le nouveau type en premier ; suppression d'un type d'origine (confirmation).
|
||||
for (var i = 0; i < 4; i++) added.MoveUpCommand.Execute(null);
|
||||
Assert.Same(added, page.Forms[0]);
|
||||
page.Forms.Single(f => f.Kind == FeedbackKinds.Other).RemoveCommand.Execute(null);
|
||||
Assert.Equal(4, page.Forms.Count);
|
||||
Assert.Empty(page.ValidationErrors);
|
||||
|
||||
await page.PublishCommand.ExecuteAsync();
|
||||
Assert.False(page.HasError, page.ErrorMessage);
|
||||
var published = FeedbackFormRules.Parse(File.ReadAllBytes(Path.Combine(directory, "feedback-forms.json")))!;
|
||||
Assert.Equal(new[] { "type_1", FeedbackKinds.Review, FeedbackKinds.Feature, FeedbackKinds.Bug },
|
||||
published.Forms.Select(f => f.Kind).ToArray());
|
||||
Assert.False(published.Forms[0].AllowAttachments);
|
||||
Assert.NotNull(published.Forms[0].CustomIcon);
|
||||
Assert.False(page.Forms[0].IsNew); // publié
|
||||
|
||||
// Huit types au plus ; « Revenir à l'origine » rétablit les quatre types d'origine.
|
||||
while (page.AddFormCommand.CanExecute(null)) page.AddFormCommand.Execute(null);
|
||||
Assert.Equal(FeedbackFormRules.MaxForms, page.Forms.Count);
|
||||
page.RestoreOriginalCommand.Execute(null);
|
||||
Assert.Equal(FeedbackForms.All.Select(f => f.Kind), page.Forms.Select(f => f.Kind));
|
||||
}
|
||||
}
|
||||
@@ -182,7 +182,7 @@ public class FeedbackPanelTests
|
||||
public void Feedback_audit_actions_have_friendly_labels()
|
||||
{
|
||||
foreach (var action in new[] { "feedback.submit", "feedback.attachment", "feedback.view", "feedback.update", "feedback.delete",
|
||||
"feedback.purge", "audit.purge", "keyring.publish", "release.sign" })
|
||||
"feedback.purge", "feedback.forms", "audit.purge", "keyring.publish", "release.sign" })
|
||||
Assert.NotEqual(action, GameLauncher.Modules.Admin.Audit.AuditTexts.Action(action));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,460 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Modules.Feedback;
|
||||
using GameLauncher.Tests.Admin;
|
||||
using GameLauncher.Tests.Signing;
|
||||
|
||||
namespace GameLauncher.Tests.Contracts;
|
||||
|
||||
/// <summary>Formulaires de démonstration modifiés « depuis le menu » (tous les types de champs).</summary>
|
||||
internal static class FeedbackFormsKit
|
||||
{
|
||||
public const string NewBugLabel = "Problème technique";
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires d'origine modifiés : bug renommé, « résultat attendu » retiré, cases à cocher, texte court et case
|
||||
/// oui/non ajoutés ; avis noté sur 10 ; « autre » masqué.
|
||||
/// </summary>
|
||||
public static List<FeedbackForm> Custom(string bugLabel = NewBugLabel)
|
||||
{
|
||||
return FeedbackForms.All.Select(form => form.Kind switch
|
||||
{
|
||||
FeedbackKinds.Bug => form with
|
||||
{
|
||||
Label = bugLabel,
|
||||
Fields = form.Fields.Where(f => f.Id != "expected")
|
||||
.Append(new FeedbackField("platform", "Où cela arrive-t-il ?", FeedbackFieldKind.Checkboxes, Required: true,
|
||||
Options: new[] { new FeedbackOption("solo", "Partie solo"), new FeedbackOption("coop", "Coopératif"), new FeedbackOption("menu", "Menus") }))
|
||||
.Append(new FeedbackField("version_seen", "Version affichée en jeu", FeedbackFieldKind.Text, Required: false,
|
||||
"En bas à droite de l'écran titre.", MaxLength: 40))
|
||||
.Append(new FeedbackField("again", "Le problème revient après un redémarrage", FeedbackFieldKind.YesNo, Required: false))
|
||||
.ToList()
|
||||
},
|
||||
FeedbackKinds.Review => form with
|
||||
{
|
||||
Fields = form.Fields.Select(f => f.Id == "rating" ? f with { RatingMax = 10 } : f).ToList()
|
||||
},
|
||||
FeedbackKinds.Other => form with { Hidden = true },
|
||||
_ => form
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Type de demande ajouté depuis le menu (un champ « Message »).</summary>
|
||||
public static FeedbackForm NewKind(string kind, string label = "Suggestion de niveau", bool attachments = true, string? icon = null) =>
|
||||
new(kind, label, "Proposez un niveau.", "Lightbulb", "", TechByDefault: false,
|
||||
new[] { new FeedbackField("message", "Message", FeedbackFieldKind.Multiline, Required: true) },
|
||||
AllowAttachments: attachments, CustomIcon: icon);
|
||||
|
||||
/// <summary>Fichier .ico : une image bitmap (BITMAPINFOHEADER) de <paramref name="size"/> pixels en 32, 24 ou 8 bits.</summary>
|
||||
public static byte[] Ico(int size = 64, int bits = 32, int count = 1)
|
||||
{
|
||||
var stride = (size * bits / 8 + 3) & ~3;
|
||||
var maskStride = (size + 31) / 32 * 4;
|
||||
var dib = new byte[40 + stride * size + maskStride * size];
|
||||
BitConverter.GetBytes(40).CopyTo(dib, 0);
|
||||
BitConverter.GetBytes(size).CopyTo(dib, 4);
|
||||
BitConverter.GetBytes(size * 2).CopyTo(dib, 8);
|
||||
BitConverter.GetBytes((ushort)1).CopyTo(dib, 12);
|
||||
BitConverter.GetBytes((ushort)bits).CopyTo(dib, 14);
|
||||
var random = new Random(size + bits);
|
||||
for (var i = 40; i < 40 + stride * size; i++) dib[i] = (byte)random.Next(256);
|
||||
return IcoOf(Enumerable.Repeat((size, dib), count).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Fichier .ico avec les images données (bitmap ou PNG).</summary>
|
||||
public static byte[] IcoOf(params (int Size, byte[] Data)[] images)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
var writer = new BinaryWriter(stream);
|
||||
writer.Write((ushort)0);
|
||||
writer.Write((ushort)1);
|
||||
writer.Write((ushort)images.Length);
|
||||
var offset = 6 + 16 * images.Length;
|
||||
foreach (var (size, data) in images)
|
||||
{
|
||||
writer.Write((byte)(size >= 256 ? 0 : size));
|
||||
writer.Write((byte)(size >= 256 ? 0 : size));
|
||||
writer.Write((byte)0);
|
||||
writer.Write((byte)0);
|
||||
writer.Write((ushort)1);
|
||||
writer.Write((ushort)32);
|
||||
writer.Write(data.Length);
|
||||
writer.Write(offset);
|
||||
offset += data.Length;
|
||||
}
|
||||
foreach (var (_, data) in images) writer.Write(data);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public static FeedbackFormsDocument Document(long revision, List<FeedbackForm>? forms = null) =>
|
||||
new() { Revision = revision, Forms = forms ?? Custom() };
|
||||
|
||||
public static FeedbackFormsDocument Signed(ECDsa key, long revision, List<FeedbackForm>? forms = null, DateTimeOffset? at = null)
|
||||
{
|
||||
var document = Document(revision, forms);
|
||||
return document with
|
||||
{
|
||||
Signature = SigningKit.Sign(key, ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(document), at)
|
||||
};
|
||||
}
|
||||
|
||||
public static byte[] Bytes(FeedbackFormsDocument document) => Encoding.UTF8.GetBytes(FeedbackFormRules.Serialize(document));
|
||||
}
|
||||
|
||||
/// <summary>Formulaires des demandes publiés depuis le menu : règles, signature, nouveaux types de champs, lecture par le launcher.</summary>
|
||||
public class FeedbackFormsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Original_forms_are_valid_and_their_signed_digest_survives_a_round_trip()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var document = FeedbackFormsKit.Document(1, FeedbackForms.All.ToList());
|
||||
Assert.True(FeedbackFormRules.Validate(document, errors), string.Join(" ", errors));
|
||||
Assert.True(FeedbackFormRules.Validate(FeedbackFormsKit.Document(1), errors), string.Join(" ", errors));
|
||||
|
||||
var custom = FeedbackFormsKit.Document(3);
|
||||
var reread = FeedbackFormRules.Parse(FeedbackFormsKit.Bytes(custom with { UpdatedAt = DateTimeOffset.UtcNow }))!;
|
||||
Assert.Equal(FeedbackFormRules.Digest(custom), FeedbackFormRules.Digest(reread));
|
||||
Assert.Contains("\"checkboxes\"", FeedbackFormRules.Serialize(custom), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Tout changement visible change le condensé (révision, libellé, ordre, masquage).
|
||||
Assert.NotEqual(FeedbackFormRules.Digest(custom), FeedbackFormRules.Digest(custom with { Revision = 4 }));
|
||||
Assert.NotEqual(FeedbackFormRules.Digest(custom), FeedbackFormRules.Digest(FeedbackFormsKit.Document(3, FeedbackFormsKit.Custom("Bogue"))));
|
||||
var hidden = FeedbackFormsKit.Custom();
|
||||
hidden[0] = hidden[0] with { Hidden = true };
|
||||
Assert.NotEqual(FeedbackFormRules.Digest(custom), FeedbackFormRules.Digest(FeedbackFormsKit.Document(3, hidden)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forms_out_of_bounds_or_with_links_are_refused()
|
||||
{
|
||||
List<FeedbackForm> With(string kind, Func<FeedbackForm, FeedbackForm> change) =>
|
||||
FeedbackFormsKit.Custom().Select(f => f.Kind == kind ? change(f) : f).ToList();
|
||||
FeedbackForm AddField(FeedbackForm form, FeedbackField field) => form with { Fields = form.Fields.Append(field).ToList() };
|
||||
|
||||
var cases = new (string Expected, List<FeedbackForm> Forms)[]
|
||||
{
|
||||
("liens", With(FeedbackKinds.Bug, f => f with { Description = "Voir www.exemple.com" })),
|
||||
("liens", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("x", "Champ", FeedbackFieldKind.Text, false, "Allez sur hxxps://site[.]ru")))),
|
||||
("liens", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("x", "Liste", FeedbackFieldKind.Choice, false,
|
||||
Options: new[] { new FeedbackOption("a", "discord.gg/abc"), new FeedbackOption("b", "B") })))),
|
||||
("identifiant", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("Mauvais Id", "Champ", FeedbackFieldKind.Text, false)))),
|
||||
("en double", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("steps", "Encore", FeedbackFieldKind.Text, false)))),
|
||||
("choix", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("x", "Liste", FeedbackFieldKind.Choice, false,
|
||||
Options: new[] { new FeedbackOption("a", "Seul") })))),
|
||||
("étoiles", With(FeedbackKinds.Review, f => f with { Fields = f.Fields.Select(x => x.Id == "rating" ? x with { RatingMax = 4 } : x).ToList() })),
|
||||
("aperçu", With(FeedbackKinds.Bug, f => f with { Fields = f.Fields.Select(x => x with { ShowInList = x.Kind == FeedbackFieldKind.Choice }).ToList() })),
|
||||
("caractères", With(FeedbackKinds.Bug, f => f with { Label = "Deux espaces" })),
|
||||
("caractères", With(FeedbackKinds.Bug, f => f with { Label = "Invisibleici" })),
|
||||
("icône", With(FeedbackKinds.Bug, f => f with { IconKey = "Delete" })),
|
||||
("longueur", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("x", "Court", FeedbackFieldKind.Text, false, MaxLength: 500)))),
|
||||
("champs au plus", With(FeedbackKinds.Bug, f => f with { Fields = Enumerable.Range(0, 16)
|
||||
.Select(i => new FeedbackField($"f{i}", $"Champ {i}", FeedbackFieldKind.YesNo, false)).ToList() })),
|
||||
("rester proposé", FeedbackFormsKit.Custom().Select(f => f with { Hidden = true }).ToList()),
|
||||
("1 à 8", Enumerable.Range(1, 9).Select(i => FeedbackFormsKit.NewKind($"type_{i}")).ToList()),
|
||||
("en double", FeedbackFormsKit.Custom().Append(FeedbackFormsKit.NewKind(FeedbackKinds.Bug)).ToList()),
|
||||
("identifiant invalide", FeedbackFormsKit.Custom().Append(FeedbackFormsKit.NewKind("Type 9")).ToList()),
|
||||
("64 × 64", With(FeedbackKinds.Bug, f => f with { CustomIcon = Convert.ToBase64String(FeedbackTestImages.PngWith("tEXt", "x")) })),
|
||||
("illisible", With(FeedbackKinds.Bug, f => f with { CustomIcon = "pas du base64 !" }))
|
||||
};
|
||||
foreach (var (expected, forms) in cases)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
Assert.False(FeedbackFormRules.Validate(FeedbackFormsKit.Document(2, forms), errors), expected);
|
||||
Assert.Contains(errors, e => e.Contains(expected, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
var revisionErrors = new List<string>();
|
||||
Assert.False(FeedbackFormRules.Validate(FeedbackFormsKit.Document(0), revisionErrors));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void New_field_kinds_are_checked_like_on_the_server()
|
||||
{
|
||||
var bug = FeedbackFormsKit.Custom().Single(f => f.Kind == FeedbackKinds.Bug);
|
||||
var review = FeedbackFormsKit.Custom().Single(f => f.Kind == FeedbackKinds.Review);
|
||||
string? Check(FeedbackField field, string? value, out List<string> errors)
|
||||
{
|
||||
errors = new List<string>();
|
||||
return FeedbackRules.CheckField(field, value, errors);
|
||||
}
|
||||
|
||||
var platform = bug.Field("platform")!;
|
||||
Assert.Equal("solo,menu", Check(platform, "menu,solo", out var e1)); // ordre du formulaire
|
||||
Assert.Empty(e1);
|
||||
Assert.Null(Check(platform, "solo,pirate", out var e2));
|
||||
Assert.NotEmpty(e2);
|
||||
Assert.Null(Check(platform, "solo,solo", out var e3));
|
||||
Assert.NotEmpty(e3);
|
||||
Assert.Null(Check(platform, null, out var e4));
|
||||
Assert.Contains("cochez", e4[0]);
|
||||
Assert.Equal("Partie solo, Menus", platform.DisplayValue("solo,menu"));
|
||||
|
||||
var again = bug.Field("again")!;
|
||||
Assert.Equal(FeedbackRules.No, Check(again, null, out _));
|
||||
Assert.Null(Check(again, "peut-être", out var e5));
|
||||
Assert.NotEmpty(e5);
|
||||
Check(again with { Required = true }, FeedbackRules.No, out var e6);
|
||||
Assert.Contains("obligatoire", e6[0]);
|
||||
Assert.Equal("Oui", again.DisplayValue(FeedbackRules.Yes));
|
||||
|
||||
var version = bug.Field("version_seen")!;
|
||||
Assert.Equal("0.9.3 bêta", Check(version, " 0.9.3 bêta ", out _));
|
||||
Assert.Null(Check(version, new string('x', 41), out var e7));
|
||||
Assert.NotEmpty(e7);
|
||||
|
||||
var rating = review.Field("rating")!;
|
||||
Assert.Equal("10", Check(rating, "10", out _));
|
||||
Assert.Null(Check(rating, "11", out _));
|
||||
Assert.Null(Check(rating, "0", out _));
|
||||
Assert.Null(Check(rating, "07", out _));
|
||||
Assert.Equal("8/10", rating.DisplayValue("8"));
|
||||
Assert.Equal("8/10", review.Badge(new Dictionary<string, string> { ["rating"] = "8" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Icons_are_imported_only_from_an_ico_file_with_a_64_pixel_image()
|
||||
{
|
||||
// Bitmap 32 bits : converti en PNG 64 × 64 neuf, accepté tel quel dans des formulaires signés.
|
||||
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.Ico(), out var png));
|
||||
Assert.Null(FeedbackImages.Sanitize(png!, out var info, out var clean));
|
||||
Assert.Equal((FeedbackImages.Png, 64, 64), (info!.Format, info.Width, info.Height));
|
||||
Assert.Equal(clean, png);
|
||||
Assert.Null(FeedbackIcons.CheckCustom(Convert.ToBase64String(png!)));
|
||||
// Bitmap 24 bits (transparence du masque), image PNG intégrée, plusieurs tailles dont 64 × 64.
|
||||
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.Ico(bits: 24), out _));
|
||||
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.IcoOf((64, png!)), out var fromPng));
|
||||
Assert.Equal(png, fromPng);
|
||||
var sixteen = FeedbackFormsKit.Ico(16);
|
||||
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.IcoOf((16, sixteen[22..]), (64, png!)), out _));
|
||||
|
||||
// Refus : pas d'image 64 × 64, 256 couleurs, autre format, fichier abîmé, PNG intégré trompeur.
|
||||
Assert.Contains("32 × 32", FeedbackIcons.FromIco(FeedbackFormsKit.Ico(32), out _));
|
||||
Assert.Contains("256 couleurs", FeedbackIcons.FromIco(FeedbackFormsKit.Ico(bits: 8), out _));
|
||||
Assert.Contains("n'est pas une icône", FeedbackIcons.FromIco(png, out _));
|
||||
Assert.Contains("n'est pas une icône", FeedbackIcons.FromIco(System.Text.Encoding.UTF8.GetBytes("MZ\0\0 programme"), out _));
|
||||
var truncated = FeedbackFormsKit.Ico();
|
||||
Assert.Contains("endommagée", FeedbackIcons.FromIco(truncated.AsSpan(0, 400), out _));
|
||||
Assert.NotNull(FeedbackIcons.FromIco(FeedbackFormsKit.IcoOf((64, FeedbackTestImages.PngWith("tEXt", "x"))), out _)); // 64 × 48
|
||||
|
||||
// Formulaires : type ajouté avec une icône importée, captures refusées pour ce type.
|
||||
var forms = FeedbackFormsKit.Custom().Append(FeedbackFormsKit.NewKind("type_1", attachments: false, icon: Convert.ToBase64String(png!))).ToList();
|
||||
var errors = new List<string>();
|
||||
Assert.True(FeedbackFormRules.Validate(FeedbackFormsKit.Document(2, forms), errors), string.Join(" ", errors));
|
||||
var submission = new FeedbackSubmission
|
||||
{
|
||||
GameId = "EZR", Kind = "type_1", Title = "Un niveau dans le gymnase", AttachmentCount = 1,
|
||||
Fields = new() { ["message"] = "Avec des zombies basketteurs." }
|
||||
};
|
||||
errors.Clear();
|
||||
Assert.Null(FeedbackRules.Validate(submission, forms[^1], errors));
|
||||
Assert.Contains(errors, e => e.Contains("captures d'écran ne sont pas acceptées"));
|
||||
errors.Clear();
|
||||
Assert.NotNull(FeedbackRules.Validate(submission with { AttachmentCount = 0 }, forms[^1], errors));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Lecture par le launcher des joueurs
|
||||
|
||||
private static readonly Uri KeyringUrl = new("https://launcher.example/catalog/keyring.json");
|
||||
private static readonly Uri FormsUrl = FeedbackFormsService.PublishedUrl(KeyringUrl.AbsoluteUri, null)!;
|
||||
|
||||
/// <summary>Serveur simulé : fichiers servis par adresse (absent : 404).</summary>
|
||||
private sealed class FakeFiles
|
||||
{
|
||||
public Dictionary<Uri, byte[]> Files { get; } = new();
|
||||
public bool Offline { get; set; }
|
||||
|
||||
public Task<byte[]> Download(Uri uri, long max, CancellationToken ct)
|
||||
{
|
||||
if (Offline) throw new HttpRequestException("hors ligne");
|
||||
if (!Files.TryGetValue(uri, out var bytes))
|
||||
throw new HttpRequestException("introuvable", null, System.Net.HttpStatusCode.NotFound);
|
||||
if (bytes.Length > max) throw new InvalidDataException("trop grand");
|
||||
return Task.FromResult(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Players_use_published_forms_only_when_signed_by_a_key_allowed_for_forms()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var designer = SigningKit.NewKey();
|
||||
using var editor = SigningKit.NewKey();
|
||||
var files = new FakeFiles();
|
||||
files.Files[KeyringUrl] = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
|
||||
SigningKit.Entry(designer, "Nicolas", SigningScopes.Forms), SigningKit.Entry(editor, "Paul", SigningScopes.Catalog))));
|
||||
ContentTrustService Trust() =>
|
||||
new(files.Download, folder.Path, KeyringUrl, new[] { SigningKit.Root(root) }, ContentSigningMode.Require);
|
||||
FeedbackFormsService Service() => new(files.Download, FormsUrl, folder.Path, Trust());
|
||||
|
||||
Assert.Equal(new Uri("https://launcher.example/catalog/feedback-forms.json"), FormsUrl);
|
||||
|
||||
// Rien de publié : formulaires d'origine.
|
||||
Assert.Equal(0, (await Service().LoadAsync(false, default)).Revision);
|
||||
|
||||
// Signés par une clé sans l'autorisation « forms », ou non signés : refusés.
|
||||
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Signed(editor, 1));
|
||||
Assert.Equal(0, (await Service().LoadAsync(false, default)).Revision);
|
||||
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Document(1));
|
||||
Assert.Equal(0, (await Service().LoadAsync(false, default)).Revision);
|
||||
|
||||
// Signés par une clé autorisée : utilisés, et gardés pour une prochaine ouverture hors ligne.
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Signed(designer, 2, at: now));
|
||||
var forms = await Service().LoadAsync(false, default);
|
||||
Assert.Equal(2, forms.Revision);
|
||||
Assert.Equal(FeedbackFormsKit.NewBugLabel, forms.Find(FeedbackKinds.Bug)!.Label);
|
||||
Assert.DoesNotContain(forms.Visible, f => f.Kind == FeedbackKinds.Other);
|
||||
files.Offline = true;
|
||||
Assert.Equal(2, (await Service().LoadAsync(false, default)).Revision);
|
||||
files.Offline = false;
|
||||
|
||||
// Retour en arrière (révision ou signature plus anciennes) : refusé, la copie gardée reste utilisée.
|
||||
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Signed(designer, 1, at: now.AddMinutes(5)));
|
||||
Assert.Equal(2, (await Service().LoadAsync(false, default)).Revision);
|
||||
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Signed(designer, 3, at: now.AddHours(-1)));
|
||||
Assert.Equal(2, (await Service().LoadAsync(false, default)).Revision);
|
||||
|
||||
// Copie gardée modifiée sur le disque : ignorée (signature revérifiée).
|
||||
var service = Service();
|
||||
File.WriteAllText(service.CachePath, FeedbackFormRules.Serialize(FeedbackFormsKit.Signed(designer, 2, at: now) with { Revision = 9 }));
|
||||
files.Offline = true;
|
||||
Assert.Equal(0, (await service.LoadAsync(false, default)).Revision);
|
||||
|
||||
// Fichier hors limites, même signé (lien dans un libellé) : refusé.
|
||||
files.Offline = false;
|
||||
var linked = FeedbackFormsKit.Custom("Voir site.ru");
|
||||
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Signed(designer, 4, linked, now.AddMinutes(10)));
|
||||
Assert.NotEqual(4, (await Service().LoadAsync(false, default)).Revision);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Fenêtre « Aide »
|
||||
|
||||
private sealed class FakeForms : IFeedbackFormsSource
|
||||
{
|
||||
public FeedbackFormsDocument Current { get; set; } = FeedbackFormsKit.Document(1);
|
||||
public int Refreshes { get; private set; }
|
||||
|
||||
public Task<FeedbackFormsDocument> LoadAsync(bool refresh, CancellationToken ct)
|
||||
{
|
||||
if (refresh) Refreshes++;
|
||||
return Task.FromResult(Current);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingSender : IFeedbackSender
|
||||
{
|
||||
public List<FeedbackSubmission> Sent { get; } = new();
|
||||
public Exception? NextFailure { get; set; }
|
||||
|
||||
public Task<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, CancellationToken ct)
|
||||
{
|
||||
if (NextFailure is { } failure)
|
||||
{
|
||||
NextFailure = null;
|
||||
throw failure;
|
||||
}
|
||||
Sent.Add(submission);
|
||||
return Task.FromResult(new FeedbackSent("FB-7K2M-Q9XD", attachments.Count, null));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_help_window_builds_published_forms_and_reloads_them_when_republished_during_input()
|
||||
{
|
||||
var forms = new FakeForms();
|
||||
var sender = new RecordingSender();
|
||||
using var form = new FeedbackFormViewModel(new FeedbackTarget("EZR", "Exovival", "0.9.3", "3.2.0"), sender, forms: forms);
|
||||
await form.FormsLoad;
|
||||
Assert.Equal(1, form.FormsRevision);
|
||||
Assert.Equal(3, form.Kinds.Count); // « autre » masqué
|
||||
form.Kinds.Single(k => k.Form.Kind == FeedbackKinds.Review).SelectCommand.Execute(null);
|
||||
Assert.Equal(10, form.Fields.Single(f => f.Id == "rating").Stars.Count);
|
||||
form.BackCommand.Execute(null);
|
||||
|
||||
form.Kinds.Single(k => k.Form.Kind == FeedbackKinds.Bug).SelectCommand.Execute(null);
|
||||
Assert.Equal(FeedbackFormsKit.NewBugLabel, form.FormTitle);
|
||||
FeedbackFieldViewModel Field(string id) => form.Fields.Single(f => f.Id == id);
|
||||
form.Title = "Le jeu se fige";
|
||||
Field("category").SelectedOption = Field("category").Options.Single(o => o.Id == "gameplay");
|
||||
Field("severity").SelectedOption = Field("severity").Options.Single(o => o.Id == "blocking");
|
||||
Field("frequency").SelectedOption = Field("frequency").Options.Single(o => o.Id == "often");
|
||||
Field("steps").Text = "Ouvrir l'inventaire.";
|
||||
Field("actual").Text = "L'image se fige.";
|
||||
Field("version_seen").Text = "0.9.3";
|
||||
Field("again").IsChecked = true;
|
||||
Assert.True(Field("version_seen").IsText && Field("again").IsYesNo && Field("platform").IsCheckboxes);
|
||||
|
||||
// Case obligatoire non cochée : refus local, rien n'est envoyé.
|
||||
await form.SendCommand.ExecuteAsync();
|
||||
Assert.Empty(sender.Sent);
|
||||
Assert.True(Field("platform").HasError);
|
||||
Field("platform").CheckOptions.Single(o => o.Option.Id == "coop").IsChecked = true;
|
||||
Field("platform").CheckOptions.Single(o => o.Option.Id == "solo").IsChecked = true;
|
||||
|
||||
// Republiés pendant la saisie (409) : relus, saisie reprise pour les champs restés identiques.
|
||||
var republished = FeedbackFormsKit.Custom("Bogue").Select(f => f.Kind != FeedbackKinds.Bug ? f : f with
|
||||
{
|
||||
Fields = f.Fields.Where(x => x.Id != "version_seen").ToList()
|
||||
}).ToList();
|
||||
forms.Current = FeedbackFormsKit.Document(2, republished);
|
||||
sender.NextFailure = new ApiException(409, ApiErrorCodes.FeedbackFormChanged, "Le formulaire vient d'être mis à jour.");
|
||||
await form.SendCommand.ExecuteAsync();
|
||||
Assert.Equal(1, forms.Refreshes);
|
||||
Assert.Contains("mis à jour", form.ErrorMessage);
|
||||
Assert.Equal("Bogue", form.FormTitle);
|
||||
Assert.Equal(2, form.FormsRevision);
|
||||
Assert.DoesNotContain(form.Fields, f => f.Id == "version_seen");
|
||||
Assert.Equal("L'image se fige.", Field("actual").Text);
|
||||
Assert.True(Field("again").IsChecked);
|
||||
Assert.Equal("blocking", Field("severity").SelectedOption!.Id);
|
||||
|
||||
await form.SendCommand.ExecuteAsync();
|
||||
Assert.True(form.IsDone, form.ErrorMessage);
|
||||
var sent = Assert.Single(sender.Sent);
|
||||
Assert.Equal(2, sent.FormRevision);
|
||||
Assert.Equal("solo,coop", sent.Fields["platform"]);
|
||||
Assert.Equal(FeedbackRules.Yes, sent.Fields["again"]);
|
||||
Assert.False(sent.Fields.ContainsKey("version_seen"));
|
||||
}
|
||||
|
||||
private sealed class NoScreenshots : IScreenshotSource
|
||||
{
|
||||
public ScreenshotResult? PickFile() => null;
|
||||
public ScreenshotResult? FromClipboard() => null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_help_window_shows_imported_icons_and_hides_screenshots_when_not_allowed()
|
||||
{
|
||||
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.Ico(), out var png));
|
||||
var forms = new FakeForms
|
||||
{
|
||||
Current = FeedbackFormsKit.Document(3, new List<FeedbackForm>
|
||||
{
|
||||
FeedbackFormsKit.NewKind("type_1", attachments: false, icon: Convert.ToBase64String(png!)),
|
||||
FeedbackForms.All[2]
|
||||
})
|
||||
};
|
||||
using var form = new FeedbackFormViewModel(new FeedbackTarget("EZR", "Exovival", "0.9.3", "3.2.0"), new RecordingSender(),
|
||||
screenshots: new NoScreenshots(), forms: forms);
|
||||
await form.FormsLoad;
|
||||
Assert.Equal(new[] { "type_1", FeedbackKinds.Bug }, form.Kinds.Select(k => k.Form.Kind).ToArray()); // ordre publié
|
||||
Assert.True(form.Kinds[0].HasCustomIcon);
|
||||
Assert.False(form.Kinds[1].HasCustomIcon);
|
||||
|
||||
form.Kinds[0].SelectCommand.Execute(null);
|
||||
Assert.True(form.HasFormCustomIcon);
|
||||
Assert.False(form.AttachmentsAllowed);
|
||||
Assert.False(form.AddFileCommand.CanExecute(null));
|
||||
form.BackCommand.Execute(null);
|
||||
form.Kinds[1].SelectCommand.Execute(null);
|
||||
Assert.True(form.AttachmentsAllowed);
|
||||
Assert.False(form.HasFormCustomIcon);
|
||||
}
|
||||
}
|
||||
@@ -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,196 @@
|
||||
using System.Net;
|
||||
using GameLauncher.ApiClient;
|
||||
using GameLauncher.Contracts;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Contracts.Security;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Server.Storage;
|
||||
using GameLauncher.Tests.Contracts;
|
||||
using GameLauncher.Tests.Infrastructure;
|
||||
using GameLauncher.Tests.Signing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace GameLauncher.Tests.Server;
|
||||
|
||||
/// <summary>Formulaires des demandes : publication signée depuis le menu, fichier public, validation des demandes par révision.</summary>
|
||||
public class FeedbackFormsModuleTests
|
||||
{
|
||||
private const string Game = "EZR";
|
||||
|
||||
private static async Task<(TestApi Api, string PublicDirectory)> StartAsync()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "launcher-forms-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"),
|
||||
["LauncherApi:Feedback:Enabled"] = "true",
|
||||
["LauncherApi:Feedback:Games:0"] = Game,
|
||||
["LauncherApi:Feedback:ProofDifficulty"] = "8",
|
||||
["LauncherApi:Feedback:RequestsPerMinute"] = "10000",
|
||||
["LauncherApi:Feedback:PerIpPer10Minutes"] = "1000",
|
||||
["LauncherApi:Feedback:PerIpPerDay"] = "10000"
|
||||
});
|
||||
return (api, directory);
|
||||
}
|
||||
|
||||
private static FeedbackSubmission CustomBug(long revision) => FeedbackModuleTests.Bug() with
|
||||
{
|
||||
FormRevision = revision,
|
||||
Fields = new Dictionary<string, string>
|
||||
{
|
||||
["category"] = "crash", ["severity"] = "blocking", ["frequency"] = "always", ["steps"] = "Ouvrir l'inventaire.",
|
||||
["actual"] = "Le jeu se ferme.", ["platform"] = "coop,solo", ["version_seen"] = "0.9.3", ["again"] = FeedbackRules.Yes
|
||||
}
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Published_forms_are_signed_public_and_used_to_check_and_display_requests()
|
||||
{
|
||||
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.GetFeedbackFormsAsync();
|
||||
Assert.Equal(0, state.Document.Revision);
|
||||
Assert.True(state.PublishingEnabled);
|
||||
Assert.EndsWith("/catalog/feedback-forms.json", state.PublicUrl);
|
||||
|
||||
state = await owner.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom(), 0);
|
||||
Assert.Equal(1, state.Document.Revision);
|
||||
Assert.Equal(key.KeyId, state.Document.Signature!.KeyId);
|
||||
Assert.NotNull(state.UpdatedBy);
|
||||
|
||||
// Fichier public à côté du trousseau, signature valable pour les launchers (condensé recalculé sur le fichier).
|
||||
var published = FeedbackFormRules.Parse(File.ReadAllBytes(Path.Combine(directory, "feedback-forms.json")))!;
|
||||
Assert.True(ContentSigning.VerifyWithKey(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(published),
|
||||
published.Signature!, key.PublicKey));
|
||||
var audit = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "feedback.forms");
|
||||
Assert.Contains("Problème technique", audit.Details);
|
||||
Assert.Contains("+3 champ(s)", audit.Details);
|
||||
|
||||
// Demande avec la révision en vigueur : nouveaux champs acceptés, formulaire conservé avec la demande.
|
||||
using var client = api.CreateClient();
|
||||
await client.SendFeedbackAsync(CustomBug(1));
|
||||
// Ancien launcher (formulaires d'origine, révision 0) : toujours accepté…
|
||||
await client.SendFeedbackAsync(FeedbackModuleTests.Bug("Ancien launcher"));
|
||||
// … sauf pour un type masqué depuis.
|
||||
var hidden = await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(FeedbackModuleTests.Bug("Autre chose") with
|
||||
{
|
||||
Kind = FeedbackKinds.Other, Fields = new() { ["message"] = "Bonjour" }, Tech = null
|
||||
}));
|
||||
Assert.Contains("n'est plus proposé", hidden.Message);
|
||||
// Révision dépassée : 409 (le launcher relit les formulaires).
|
||||
var stale = await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(CustomBug(7)));
|
||||
Assert.Equal(409, stale.Status);
|
||||
Assert.Equal(ApiErrorCodes.FeedbackFormChanged, stale.Code);
|
||||
// Champ inconnu de la révision annoncée : refusé.
|
||||
await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(CustomBug(0)));
|
||||
|
||||
var items = (await owner.GetFeedbackPageAsync(Game)).Items;
|
||||
var custom = await owner.GetFeedbackAsync(Game, items.Single(i => i.Title != "Ancien launcher").Id);
|
||||
Assert.Equal(1, custom.FormRevision);
|
||||
Assert.Equal("Problème technique", custom.Form!.Label);
|
||||
Assert.Equal("solo,coop", custom.Fields["platform"]);
|
||||
Assert.Equal("Bloquant", custom.Summary.Badge);
|
||||
var legacy = await owner.GetFeedbackAsync(Game, items.Single(i => i.Title == "Ancien launcher").Id);
|
||||
Assert.Equal(0, legacy.FormRevision);
|
||||
Assert.Null(legacy.Form);
|
||||
Assert.Equal("Plantage", legacy.Summary.Badge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publication_needs_the_permission_the_session_key_and_the_current_revision()
|
||||
{
|
||||
var (api, _) = await StartAsync();
|
||||
await using var __ = api;
|
||||
using var key = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(key);
|
||||
|
||||
// Éditeur (sans feedback.forms) : refusé.
|
||||
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.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom(), 0));
|
||||
Assert.Equal(403, denied.Status);
|
||||
Assert.Equal(0, (await editor.GetFeedbackFormsAsync()).Document.Revision); // lecture : contenu public
|
||||
}
|
||||
editorKey.Dispose();
|
||||
|
||||
await owner.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom(), 0);
|
||||
// Publication fondée sur une révision dépassée : 409, rien n'est écrasé.
|
||||
var conflict = await Assert.ThrowsAsync<ApiException>(() => owner.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom("Bogue"), 0));
|
||||
Assert.Equal(409, conflict.Status);
|
||||
|
||||
// Formulaires signés par une autre clé que celle de la session, ou non signés : refusés.
|
||||
var login = await api.RawLoginAsync(key);
|
||||
using var stranger = SigningKit.NewKey();
|
||||
foreach (var document in new[] { FeedbackFormsKit.Signed(stranger, 2), FeedbackFormsKit.Document(2) })
|
||||
{
|
||||
using var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.FeedbackForms,
|
||||
new FeedbackFormsPublishRequest { BaseRevision = 1, Document = document });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
// Formulaires hors règles (lien), même bien signés : refusés par le serveur.
|
||||
var linked = FeedbackFormsKit.Document(2, FeedbackFormsKit.Custom("Voir www.site.com"));
|
||||
var signature = await owner.SignContentAsync(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(linked));
|
||||
using (var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.FeedbackForms,
|
||||
new FeedbackFormsPublishRequest { BaseRevision = 1, Document = linked with { Signature = signature } }))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Contains("liens", (await TestApi.ReadErrorAsync(response)).Message);
|
||||
}
|
||||
Assert.Equal(1, (await owner.GetFeedbackFormsAsync()).Document.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Categories_can_be_added_removed_reordered_with_imported_icons_and_without_screenshots()
|
||||
{
|
||||
var (api, directory) = await StartAsync();
|
||||
await using var _ = api;
|
||||
using var key = api.AddOwner();
|
||||
await using var owner = await api.LoginAsync(key);
|
||||
|
||||
// Huit types (le maximum), chacun avec une icône importée : publication au-delà de 64 Ko acceptée par cette route.
|
||||
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.Ico(), out var png));
|
||||
var icon = Convert.ToBase64String(png!);
|
||||
var forms = new List<FeedbackForm> { FeedbackFormsKit.NewKind("type_1", "Suggestion de niveau", attachments: false, icon: icon) }
|
||||
.Concat(FeedbackForms.All.Where(f => f.Kind != FeedbackKinds.Other).Select(f => f with { CustomIcon = icon }))
|
||||
.Concat(Enumerable.Range(2, 4).Select(i => FeedbackFormsKit.NewKind($"type_{i}", $"Type {i}", icon: icon)))
|
||||
.ToList();
|
||||
Assert.Equal(FeedbackFormRules.MaxForms, forms.Count);
|
||||
var state = await owner.PublishFeedbackFormsAsync(forms, 0);
|
||||
Assert.Equal("type_1", state.Document.Forms[0].Kind); // ordre conservé
|
||||
Assert.Equal(icon, state.Document.Forms[0].CustomIcon);
|
||||
Assert.True(new FileInfo(Path.Combine(directory, "feedback-forms.json")).Length > 64 * 1024);
|
||||
var audit = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "feedback.forms");
|
||||
Assert.Contains("type(s) ajouté(s)", audit.Details);
|
||||
Assert.Contains("« Autre »", audit.Details);
|
||||
|
||||
using var client = api.CreateClient();
|
||||
// Nouveau type : accepté, mais sans capture d'écran.
|
||||
var suggestion = new FeedbackSubmission
|
||||
{
|
||||
GameId = Game, Kind = "type_1", FormRevision = 1, Title = "Un niveau dans le gymnase",
|
||||
Fields = new() { ["message"] = "Avec des zombies basketteurs." }
|
||||
};
|
||||
var refused = await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(suggestion, new[] { FeedbackTestImages.PngWith("tEXt", "x") }));
|
||||
Assert.Contains("captures d'écran ne sont pas acceptées", refused.Message);
|
||||
await client.SendFeedbackAsync(suggestion);
|
||||
// Type d'origine supprimé : refusé aussi aux anciens launchers (formulaires d'origine).
|
||||
var removed = await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(FeedbackModuleTests.Bug("Autre chose") with
|
||||
{
|
||||
Kind = FeedbackKinds.Other, Fields = new() { ["message"] = "Bonjour" }, Tech = null
|
||||
}));
|
||||
Assert.Contains("n'est plus proposé", removed.Message);
|
||||
|
||||
// Liste du menu : filtre par le nouveau type, nom et icône conservés avec la demande.
|
||||
var page = await owner.GetFeedbackPageAsync(Game, "type_1", null);
|
||||
var detail = await owner.GetFeedbackAsync(Game, Assert.Single(page.Items).Id);
|
||||
Assert.Equal("Suggestion de niveau", detail.Form!.Label);
|
||||
Assert.Equal(icon, detail.Form.CustomIcon);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -166,15 +166,22 @@ public class ContentSigningTests
|
||||
Assert.Contains("inconnue", error);
|
||||
Assert.False(Keyring.VerifyRoot(keyring with { Signature = null }, roots, out _));
|
||||
|
||||
// Structure : identifiant ne correspondant pas à la clé, autorisation inconnue.
|
||||
// Structure : identifiant ne correspondant pas à la clé, autorisation mal formée.
|
||||
var errors = new List<string>();
|
||||
Assert.False(Keyring.Validate(keyring with
|
||||
{
|
||||
Keys = new() { SigningKit.Entry(admin, "Nicolas") with { KeyId = SigningKit.KeyId(intruder) }, SigningKit.Entry(intruder, "X", "admin") }
|
||||
Keys = new() { SigningKit.Entry(admin, "Nicolas") with { KeyId = SigningKit.KeyId(intruder) }, SigningKit.Entry(intruder, "X", "Admin !") }
|
||||
}, errors));
|
||||
Assert.Contains(errors, e => e.Contains("ne correspond pas"));
|
||||
Assert.Contains(errors, e => e.Contains("autorisations"));
|
||||
|
||||
// Autorisation inconnue mais bien formée (ajoutée par une version plus récente) : trousseau accepté, elle n'autorise rien ici.
|
||||
errors.Clear();
|
||||
var future = keyring with { Keys = new() { SigningKit.Entry(admin, "Nicolas", SigningScopes.Catalog, "news") } };
|
||||
Assert.True(Keyring.Validate(future, errors), string.Join(" ", errors));
|
||||
Assert.False(ContentSigning.Verify(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, "x",
|
||||
SigningKit.Sign(admin, ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, "x"), future).Valid);
|
||||
|
||||
// Membres absents (« keys »: null, « scopes »: null…) : refusés sans exception.
|
||||
foreach (var json in new[]
|
||||
{
|
||||
|
||||
@@ -271,6 +271,74 @@ public class ContentTrustTests
|
||||
Assert.Equal(2, server.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_scope_granted_while_the_launcher_runs_is_found_by_downloading_the_keyring_again()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var nicolas = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas", SigningScopes.Releases)));
|
||||
var clock = new ManualClock();
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
|
||||
var catalog = new CatalogDocument { Revision = 1, Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Texte" } } };
|
||||
catalog = catalog with
|
||||
{
|
||||
Signature = SigningKit.Sign(nicolas, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog))
|
||||
};
|
||||
Assert.False(await trust.VerifyCatalogAsync(catalog)); // clé connue, sans l'autorisation « catalog »
|
||||
Assert.Equal(1, server.Requests);
|
||||
|
||||
// Trousseau republié avec l'autorisation (ex. « maintenance » après la mise à jour 3.3) : vu sans redémarrer,
|
||||
// au plus une fois par KeyChangeRetryDelay.
|
||||
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog)));
|
||||
Assert.False(await trust.VerifyCatalogAsync(catalog));
|
||||
Assert.Equal(1, server.Requests);
|
||||
clock.Now += ContentTrustService.KeyChangeRetryDelay + TimeSpan.FromSeconds(1);
|
||||
Assert.True(await trust.VerifyCatalogAsync(catalog));
|
||||
Assert.Equal(2, server.Requests);
|
||||
Assert.Equal(2, trust.CachedKeyring!.Sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_launcher_left_open_downloads_the_keyring_again_and_stops_trusting_a_removed_key()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var nicolas = SigningKit.NewKey();
|
||||
using var compromised = SigningKit.NewKey();
|
||||
var server = new FakeServer();
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(compromised, "PC volé")));
|
||||
var clock = new ManualClock();
|
||||
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
|
||||
await trust.VerifyManifestAsync(Manifest("1.2", compromised), "EZR");
|
||||
|
||||
// Clé retirée du trousseau (n° 2) : encore acceptée tant que le trousseau en mémoire est récent…
|
||||
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas")));
|
||||
clock.Now += TimeSpan.FromMinutes(10);
|
||||
await trust.VerifyManifestAsync(Manifest("1.3", compromised), "EZR");
|
||||
Assert.Equal(1, server.Requests);
|
||||
|
||||
// … puis refusée dès qu'il a dépassé son âge maximal (sans redémarrer le launcher).
|
||||
clock.Now += ContentTrustService.KeyringMaxAge;
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.4", compromised), "EZR"));
|
||||
Assert.Equal(2, server.Requests);
|
||||
await trust.VerifyManifestAsync(Manifest("1.4", nicolas), "EZR");
|
||||
|
||||
// Serveur injoignable au téléchargement suivant : dernier trousseau vu (n° 2) conservé, pas d'erreur.
|
||||
server.Keyring = null;
|
||||
clock.Now += ContentTrustService.KeyringMaxAge + TimeSpan.FromMinutes(1);
|
||||
await trust.VerifyManifestAsync(Manifest("1.5", nicolas), "EZR");
|
||||
Assert.Equal(3, server.Requests);
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.6", compromised), "EZR"));
|
||||
|
||||
// Serveur compromis qui ressert l'ancien trousseau n° 1 : refusé (retour en arrière), la clé reste exclue.
|
||||
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(compromised, "PC volé")));
|
||||
clock.Now += ContentTrustService.KeyringMaxAge + TimeSpan.FromMinutes(1);
|
||||
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.7", compromised), "EZR"));
|
||||
Assert.Equal(2, trust.CachedKeyring!.Sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_signature_dated_far_in_the_future_does_not_block_later_releases()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Contracts.Signing;
|
||||
using GameLauncher.Core.Maintenance;
|
||||
using GameLauncher.Core.Trust;
|
||||
using GameLauncher.Tests.Admin;
|
||||
|
||||
namespace GameLauncher.Tests.Signing;
|
||||
|
||||
/// <summary>Mode maintenance lu par le launcher des joueurs : vérification, retour en arrière, hors ligne, changements signalés.</summary>
|
||||
public class MaintenanceServiceTests
|
||||
{
|
||||
private static readonly Uri KeyringUrl = new("https://launcher.example/catalog/keyring.json");
|
||||
private static readonly Uri MaintenanceUrl = new("https://launcher.example/catalog/maintenance.json");
|
||||
|
||||
/// <summary>Serveur simulé : trousseau et maintenance.json (absent : 404 ; Offline : réseau coupé).</summary>
|
||||
private sealed class FakeServer
|
||||
{
|
||||
public byte[]? Keyring { get; set; }
|
||||
public byte[]? Maintenance { get; set; }
|
||||
public bool Offline { get; set; }
|
||||
public int MaintenanceRequests { get; private set; }
|
||||
|
||||
public Task<byte[]> Download(Uri uri, long max, CancellationToken ct)
|
||||
{
|
||||
if (Offline) throw new HttpRequestException("hors ligne");
|
||||
if (uri == KeyringUrl) return Keyring is { } k ? Task.FromResult(k) : throw new HttpRequestException("404", null, HttpStatusCode.NotFound);
|
||||
MaintenanceRequests++;
|
||||
if (Maintenance is not { } bytes) throw new HttpRequestException("404", null, HttpStatusCode.NotFound);
|
||||
if (bytes.Length > max) throw new InvalidDataException("réponse trop volumineuse");
|
||||
return Task.FromResult(bytes);
|
||||
}
|
||||
|
||||
public void Serve(MaintenanceDocument document) => Maintenance = Encoding.UTF8.GetBytes(MaintenanceRules.Serialize(document));
|
||||
}
|
||||
|
||||
private static MaintenanceDocument Signed(ECDsa key, long revision, bool enabled, string? message = null, DateTimeOffset? signedAt = null)
|
||||
{
|
||||
var document = new MaintenanceDocument
|
||||
{
|
||||
Revision = revision,
|
||||
Enabled = enabled,
|
||||
Message = enabled ? message : null,
|
||||
Since = enabled ? DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeSeconds()) : null
|
||||
};
|
||||
return document with
|
||||
{
|
||||
Signature = SigningKit.Sign(key, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static (MaintenanceService Service, FakeServer Server, List<MaintenanceDocument?> Changes) Create(
|
||||
string folder, ECDsa root, ECDsa admin, ContentSigningMode mode, params string[] scopes)
|
||||
{
|
||||
var server = new FakeServer();
|
||||
server.Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
|
||||
SigningKit.Entry(admin, "Nicolas", scopes.Length == 0 ? new[] { SigningScopes.Maintenance } : scopes))));
|
||||
var trust = mode == ContentSigningMode.Off
|
||||
? null
|
||||
: new ContentTrustService(server.Download, folder, KeyringUrl, new[] { SigningKit.Root(root) }, mode);
|
||||
var service = new MaintenanceService(server.Download, MaintenanceUrl, trust);
|
||||
var changes = new List<MaintenanceDocument?>();
|
||||
service.Changed += changes.Add;
|
||||
return (service, server, changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signed_maintenance_is_applied_then_ended_and_each_change_is_reported_once()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
|
||||
// Rien de publié (404) : pas de maintenance, rien à signaler.
|
||||
Assert.False(await service.RefreshAsync());
|
||||
Assert.Empty(changes);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
server.Serve(Signed(admin, 1, true, "Retour vers 18 h.", now.AddMinutes(-3)));
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.True(service.IsActive);
|
||||
Assert.Equal("Retour vers 18 h.", Assert.Single(changes)!.Message);
|
||||
|
||||
// Même fichier : rien de nouveau.
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.Single(changes);
|
||||
|
||||
// Nouveau message : signalé.
|
||||
server.Serve(Signed(admin, 2, true, "Retour vers 19 h.", now.AddMinutes(-2)));
|
||||
await service.RefreshAsync();
|
||||
Assert.Equal(2, changes.Count);
|
||||
Assert.Equal("Retour vers 19 h.", changes[^1]!.Message);
|
||||
|
||||
// Coupure réseau pendant la maintenance : dernier état vérifié conservé.
|
||||
server.Offline = true;
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.Equal(2, changes.Count);
|
||||
server.Offline = false;
|
||||
|
||||
// Fin de la maintenance.
|
||||
server.Serve(Signed(admin, 3, false, signedAt: now.AddMinutes(-1)));
|
||||
Assert.False(await service.RefreshAsync());
|
||||
Assert.False(service.IsActive);
|
||||
Assert.Equal(3, changes.Count);
|
||||
Assert.False(changes[^1]!.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task In_require_mode_forged_unsigned_or_replayed_maintenance_is_ignored()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
using var attacker = SigningKit.NewKey();
|
||||
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Non signé, signé par une clé absente du trousseau, ou contenu modifié après signature : ignorés.
|
||||
var unsigned = Signed(admin, 1, true) with { Signature = null };
|
||||
var forged = Signed(attacker, 1, true);
|
||||
var tampered = Signed(admin, 1, true, "Message d'origine") with { Message = "Message modifié" };
|
||||
foreach (var document in new[] { unsigned, forged, tampered })
|
||||
{
|
||||
server.Serve(document);
|
||||
Assert.False(await service.RefreshAsync());
|
||||
}
|
||||
Assert.Empty(changes);
|
||||
|
||||
// Légitime : maintenance, puis fin (signée plus tard).
|
||||
var old = Signed(admin, 1, true, signedAt: now.AddMinutes(-10));
|
||||
server.Serve(old);
|
||||
Assert.True(await service.RefreshAsync());
|
||||
server.Serve(Signed(admin, 2, false, signedAt: now.AddMinutes(-5)));
|
||||
Assert.False(await service.RefreshAsync());
|
||||
|
||||
// Un serveur compromis ressert l'ancienne maintenance légitime : refusée (révision et date de signature).
|
||||
server.Serve(old);
|
||||
Assert.False(await service.RefreshAsync());
|
||||
|
||||
// Même après un redémarrage du launcher (date de la dernière signature acceptée gardée dans l'état des signatures).
|
||||
var (restarted, restartedServer, _) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
restartedServer.Serve(old);
|
||||
Assert.False(await restarted.RefreshAsync());
|
||||
|
||||
// Hors limites, même bien signé : ignoré.
|
||||
var linked = new MaintenanceDocument { Revision = 3, Enabled = true, Message = "Infos : discord.gg/x", Since = old.Since };
|
||||
server.Serve(linked with
|
||||
{
|
||||
Signature = SigningKit.Sign(admin, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(linked))
|
||||
});
|
||||
Assert.False(await service.RefreshAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_key_without_the_maintenance_scope_cannot_start_a_maintenance()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require, SigningScopes.Catalog, SigningScopes.Releases);
|
||||
server.Serve(Signed(admin, 1, true));
|
||||
Assert.False(await service.RefreshAsync());
|
||||
Assert.Empty(changes);
|
||||
}
|
||||
|
||||
/// <summary>Horloge avancée à la main (trousseau).</summary>
|
||||
private sealed class ManualClock : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_maintenance_published_after_the_keyring_gained_the_scope_is_applied_without_restarting()
|
||||
{
|
||||
// Cas signalé en 3.3.0 : launcher ouvert, trousseau republié avec l'autorisation « maintenance » (étape 3 decies),
|
||||
// puis maintenance activée. Le launcher gardait l'ancien trousseau (clé connue, sans l'autorisation) jusqu'au
|
||||
// redémarrage : chaque relecture refusait la maintenance.
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
var server = new FakeServer
|
||||
{
|
||||
Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
|
||||
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog))))
|
||||
};
|
||||
var clock = new ManualClock();
|
||||
var trust = new ContentTrustService(server.Download, folder.Path, KeyringUrl, new[] { SigningKit.Root(root) },
|
||||
ContentSigningMode.Require, time: clock);
|
||||
var service = new MaintenanceService(server.Download, MaintenanceUrl, trust, clock);
|
||||
var changes = new List<MaintenanceDocument?>();
|
||||
service.Changed += changes.Add;
|
||||
|
||||
// Démarrage : rien de publié, trousseau n° 1 en mémoire.
|
||||
Assert.NotNull(await trust.GetKeyringAsync());
|
||||
Assert.False(await service.RefreshAsync());
|
||||
|
||||
// Trousseau n° 2 (autorisation « maintenance » ajoutée), puis maintenance activée quelques minutes plus tard.
|
||||
server.Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 2,
|
||||
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog, SigningScopes.Maintenance))));
|
||||
clock.Now += TimeSpan.FromMinutes(3);
|
||||
server.Serve(Signed(admin, 1, true, "Retour vers 18 h."));
|
||||
|
||||
// Relecture périodique suivante : trousseau retéléchargé, maintenance appliquée.
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.True(service.IsActive);
|
||||
Assert.Equal("Retour vers 18 h.", Assert.Single(changes)!.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Offline_at_startup_or_without_address_there_is_no_maintenance_and_warn_mode_applies_it()
|
||||
{
|
||||
using var folder = new TempFolder();
|
||||
using var root = SigningKit.NewKey();
|
||||
using var admin = SigningKit.NewKey();
|
||||
|
||||
// Hors ligne au démarrage : jamais de maintenance (le jeu installé reste jouable).
|
||||
var (offline, offlineServer, _) = Create(folder.Path, root, admin, ContentSigningMode.Require);
|
||||
offlineServer.Offline = true;
|
||||
Assert.False(await offline.RefreshAsync());
|
||||
Assert.Null(offline.Current);
|
||||
|
||||
// Sans adresse (ni trousseau ni catalogue) : rien n'est lu.
|
||||
Assert.Null(MaintenanceService.PublishedUrl(null, null));
|
||||
Assert.Equal(MaintenanceUrl, MaintenanceService.PublishedUrl(KeyringUrl.AbsoluteUri, null));
|
||||
var none = new MaintenanceService((_, _, _) => throw new InvalidOperationException("aucune requête attendue"), null, null);
|
||||
Assert.False(await none.RefreshAsync());
|
||||
|
||||
// « warn » : document non signé appliqué (signalé dans le journal) ; « off » : appliqué sans vérification.
|
||||
foreach (var mode in new[] { ContentSigningMode.Warn, ContentSigningMode.Off })
|
||||
{
|
||||
using var modeFolder = new TempFolder();
|
||||
var (service, server, changes) = Create(modeFolder.Path, root, admin, mode);
|
||||
server.Serve(Signed(admin, 1, true) with { Signature = null });
|
||||
Assert.True(await service.RefreshAsync());
|
||||
Assert.Single(changes);
|
||||
}
|
||||
|
||||
// Fichier trop volumineux : ignoré.
|
||||
var (big, bigServer, bigChanges) = Create(folder.Path, root, admin, ContentSigningMode.Off);
|
||||
bigServer.Maintenance = new byte[MaintenanceRules.MaxBytes + 1];
|
||||
Assert.False(await big.RefreshAsync());
|
||||
Assert.Empty(bigChanges);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -169,6 +170,16 @@ internal sealed class FakeApi : HttpMessageHandler
|
||||
// ---- Demandes des joueurs (lecture, traitement accepté sans rien enregistrer, captures d'écran)
|
||||
case ["v1", "feedback", "overview"] when get:
|
||||
return Json(_world.FeedbackCounts());
|
||||
case ["v1", "feedback", "forms"] when get:
|
||||
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:
|
||||
@@ -705,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,
|
||||
@@ -720,6 +767,21 @@ internal sealed class SampleWorld
|
||||
|
||||
// ------------------------------------------------------------------ Demandes des joueurs
|
||||
|
||||
/// <summary>Formulaires publiés de démonstration (révision 3, signés par le propriétaire).</summary>
|
||||
public FeedbackFormsState FormsState() => new()
|
||||
{
|
||||
Document = SampleData.SampleFeedbackForms(),
|
||||
PublishingEnabled = true,
|
||||
PublicUrl = "https://launcher.example/catalog/feedback-forms.json",
|
||||
UpdatedBy = OwnerName
|
||||
};
|
||||
|
||||
/// <summary>Publication acceptée sans rien enregistrer : révision suivante renvoyée.</summary>
|
||||
public FeedbackFormsState FormsPublished(FeedbackFormsPublishRequest request) => FormsState() with
|
||||
{
|
||||
Document = request.Document with { UpdatedAt = DateTimeOffset.Now }
|
||||
};
|
||||
|
||||
public FeedbackOverview FeedbackCounts() => new()
|
||||
{
|
||||
Enabled = true,
|
||||
|
||||
@@ -8,6 +8,8 @@ using System.Windows.Media;
|
||||
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;
|
||||
|
||||
@@ -257,6 +259,85 @@ internal static class SampleData
|
||||
|
||||
public static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
|
||||
// ------------------------------------------------------------------ Formulaires des demandes
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
UpdatedAt = DateTimeOffset.Now.AddDays(-2),
|
||||
// Type ajouté depuis le menu, en premier : icône importée (.ico 64 × 64), sans captures d'écran.
|
||||
Forms = new[]
|
||||
{
|
||||
new FeedbackForm("type_1", "Suggestion de niveau", "Proposez un lieu ou une mission pour la suite.", "Lightbulb",
|
||||
"Ex. : un niveau dans le gymnase", TechByDefault: false,
|
||||
new[]
|
||||
{
|
||||
new FeedbackField("message", "Votre idée", FeedbackFieldKind.Multiline, Required: true, "Le lieu, l'ambiance, les objectifs…"),
|
||||
new FeedbackField("mode", "Pour quel mode ?", FeedbackFieldKind.Choice, Required: false, Options: new[]
|
||||
{
|
||||
new FeedbackOption("solo", "Solo"), new FeedbackOption("coop", "Coopératif")
|
||||
})
|
||||
},
|
||||
AllowAttachments: false, CustomIcon: Convert.ToBase64String(SampleIcon()))
|
||||
}.Concat(FeedbackForms.All.Select(form => form.Kind switch
|
||||
{
|
||||
FeedbackKinds.Bug => form with
|
||||
{
|
||||
Label = "Problème technique",
|
||||
Fields = form.Fields.Where(f => f.Id is "category" or "severity" or "steps")
|
||||
.Append(new FeedbackField("platform", "Où cela arrive-t-il ?", FeedbackFieldKind.Checkboxes, Required: true,
|
||||
"Plusieurs réponses possibles.", Options: new[]
|
||||
{
|
||||
new FeedbackOption("solo", "Partie solo"), new FeedbackOption("coop", "Coopératif"),
|
||||
new FeedbackOption("menu", "Menus et options")
|
||||
}))
|
||||
.Append(new FeedbackField("rating10", "Gêne pour jouer", FeedbackFieldKind.Rating, Required: false,
|
||||
"1 : à peine visible, 10 : injouable.", RatingMax: 10))
|
||||
.Append(new FeedbackField("version_seen", "Version affichée en jeu", FeedbackFieldKind.Text, Required: false,
|
||||
"En bas à droite de l'écran titre", MaxLength: 40))
|
||||
.Append(new FeedbackField("again", "Le problème revient après un redémarrage du jeu", FeedbackFieldKind.YesNo,
|
||||
Required: false, "Cochez si le problème réapparaît."))
|
||||
.ToList()
|
||||
},
|
||||
FeedbackKinds.Other => form with { Hidden = true },
|
||||
_ => form
|
||||
})).ToList()
|
||||
};
|
||||
|
||||
/// <summary>Icône « importée » de démonstration : PNG 64 × 64 sans métadonnées (comme après l'import d'un .ico).</summary>
|
||||
public static byte[] SampleIcon()
|
||||
{
|
||||
var visual = new DrawingVisual();
|
||||
using (var dc = visual.RenderOpen())
|
||||
{
|
||||
var background = new LinearGradientBrush(Color.FromRgb(0x2E, 0x7D, 0x32), Color.FromRgb(0x8B, 0xC3, 0x4A), 45);
|
||||
dc.DrawRoundedRectangle(background, null, new Rect(2, 2, 60, 60), 14, 14);
|
||||
dc.DrawEllipse(Brushes.White, null, new Point(32, 26), 12, 12);
|
||||
dc.DrawRectangle(Brushes.White, null, new Rect(26, 38, 12, 14));
|
||||
}
|
||||
var bitmap = new RenderTargetBitmap(FeedbackIcons.Size, FeedbackIcons.Size, 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(visual);
|
||||
var encoder = new PngBitmapEncoder { Interlace = PngInterlaceOption.Off };
|
||||
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||
using var stream = new MemoryStream();
|
||||
encoder.Save(stream);
|
||||
return FeedbackImages.Sanitize(stream.ToArray(), out _, out var clean) is null ? clean! : stream.ToArray();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Captures d'écran (demandes des joueurs)
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -32,20 +32,30 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
|
||||
|
||||
* **Fenêtre principale** (`main-*`) : non installé, à jour, mise à jour disponible, téléchargement en
|
||||
cours, préparation, installation introuvable, réparation nécessaire, jeu lancé, hors ligne, mise à jour
|
||||
bloquée par sécurité, démarrage ; `main-multi` : plusieurs jeux (exemple `launcher.multi-jeux.example.json`).
|
||||
bloquée par sécurité, démarrage, **mode maintenance** (`main-maintenance` avec la précision de l'administrateur,
|
||||
`main-maintenance-plain` sans précision, jeu non installé) ; `main-multi` : plusieurs jeux (exemple
|
||||
`launcher.multi-jeux.example.json`).
|
||||
* **Fenêtres** : paramètres du jeu et du launcher, notes de version agrandies, choix du dossier
|
||||
d'installation, mise à jour du launcher (téléchargement, version vérifiée, erreur), boîtes de dialogue
|
||||
(information, succès, question, avertissement, erreur, action irréversible avec option), menu de la zone
|
||||
de notification.
|
||||
* **Fenêtre « Aide »** (`feedback-*`, bouton de la barre de titre) : choix du type de demande, avis noté,
|
||||
bug rempli avec une capture d'écran (formulaire qui défile : pages `.p2.png`…), champs obligatoires
|
||||
signalés, demande envoyée (avec l'avertissement d'une capture refusée). Envoi simulé, capture d'écran de
|
||||
signalés, types publiés depuis le menu (`feedback-custom-kinds` : ordre, type ajouté avec une icône
|
||||
importée, type masqué), formulaire publié avec tous les types de champs (`feedback-custom` : cases à
|
||||
cocher, note sur 10, texte court, case oui/non ; type « autre » masqué), demande envoyée (avec l'avertissement d'une capture refusée). Envoi simulé, capture d'écran de
|
||||
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, et la fiche d'une demande de joueur (`admin-page-feedback-requests-detail` :
|
||||
lien signalé, captures d'écran).
|
||||
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é), 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;
|
||||
@@ -46,6 +47,9 @@ internal sealed class AdminScenes : ISceneSet
|
||||
(PagePrefix + "changelog-notes-game", "Administration · notes de version (jeu choisi)"),
|
||||
(PagePrefix + "releases-publish-ready", "Administration · version prête à publier"),
|
||||
(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")
|
||||
};
|
||||
|
||||
@@ -239,6 +243,63 @@ internal sealed class AdminScenes : ISceneSet
|
||||
await ShootAllThemesAsync(feedbackId, "Administration · demande d'un joueur (lien signalé, captures d'écran)");
|
||||
}
|
||||
|
||||
// ---- Formulaires des demandes : type « bug » modifié (champ ajouté et déplié), erreur (lien) et avertissement
|
||||
// (donnée personnelle) affichés, aperçu à jour. Rien n'est publié.
|
||||
var formsId = PagePrefix + "feedback-forms-edit";
|
||||
if (capture.WantsAny(capture.Themes.Select(t => formsId + Capture.Suffix(t))) &&
|
||||
workspace.PanelItems.FirstOrDefault(p => p.Descriptor.Id == FeedbackFormsPanelViewModel.PanelId) is { } formsItem)
|
||||
{
|
||||
workspace.SelectedPanel = formsItem;
|
||||
await WaitForPanelAsync(capture, formsItem, api);
|
||||
if (formsItem.ViewModel is FeedbackFormsPanelViewModel forms &&
|
||||
forms.Forms.FirstOrDefault(f => f.Kind == GameLauncher.Contracts.Feedback.FeedbackKinds.Bug) is { } bug)
|
||||
{
|
||||
forms.SelectedForm = bug;
|
||||
bug.Description = "Plus d'informations sur www.exemple.com";
|
||||
bug.FieldKinds.First(k => k.Kind == GameLauncher.Contracts.Feedback.FeedbackFieldKind.Checkboxes).AddCommand.Execute(null);
|
||||
var added = bug.Fields.Last();
|
||||
added.Label = "Sur quel matériel ?";
|
||||
added.Hint = "Indiquez aussi votre adresse e-mail si besoin";
|
||||
added.Options[0].Label = "Manette";
|
||||
added.Options[1].Label = "Clavier et souris";
|
||||
}
|
||||
else
|
||||
{
|
||||
capture.GlobalNotes.Add("⚠ Formulaires des demandes : page ou type « bug » introuvable.");
|
||||
}
|
||||
await ShootAllThemesAsync(formsId, "Administration · formulaire en cours de modification (champ ajouté, contrôles, aperçu)");
|
||||
|
||||
// Type ajouté : icône importée (comme depuis un .ico 64 × 64), captures d'écran désactivées, placé en deuxième.
|
||||
var newTypeId = PagePrefix + "feedback-forms-new-type";
|
||||
if (capture.WantsAny(capture.Themes.Select(t => newTypeId + Capture.Suffix(t))) && formsItem.ViewModel is FeedbackFormsPanelViewModel page &&
|
||||
page.AddFormCommand.CanExecute(null))
|
||||
{
|
||||
page.AddFormCommand.Execute(null);
|
||||
var newType = page.SelectedForm!;
|
||||
newType.Label = "Problème de compte";
|
||||
newType.Description = "Connexion, sauvegarde en ligne, succès.";
|
||||
newType.SetCustomIcon(Convert.ToBase64String(SampleData.SampleIcon()));
|
||||
newType.AllowAttachments = false;
|
||||
while (page.Forms.IndexOf(newType) > 1) newType.MoveUpCommand.Execute(null);
|
||||
await ShootAllThemesAsync(newTypeId, "Administration · type de demande ajouté (icône importée, sans captures d'écran)");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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");
|
||||
|
||||
@@ -8,7 +8,8 @@ namespace UiSnapshot.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Fenêtre « Aide » du joueur (bouton de la barre de titre) : choix du type, formulaires (avis noté, bug rempli
|
||||
/// avec une capture d'écran, champs obligatoires signalés), demande envoyée. Vrai ViewModel et vraie fenêtre ;
|
||||
/// avec une capture d'écran, champs obligatoires signalés, formulaire publié depuis le menu avec tous les types de
|
||||
/// champs), demande envoyée. Vrai ViewModel et vraie fenêtre ;
|
||||
/// l'envoi est simulé (aucun réseau) et la capture d'écran est une image de démonstration (ni fichier choisi,
|
||||
/// ni presse-papiers). Un formulaire plus haut que la fenêtre donne des pages supplémentaires (.p2.png…).
|
||||
/// </summary>
|
||||
@@ -46,6 +47,18 @@ internal sealed class FeedbackScenes : ISceneSet
|
||||
form.Title = "Bug";
|
||||
await form.SendCommand.ExecuteAsync(); // contrôle local : rien n'est envoyé
|
||||
}),
|
||||
("feedback-custom-kinds", "Aide · types publiés depuis le menu (ordre, type ajouté avec icône importée, type masqué)",
|
||||
_ => Task.CompletedTask),
|
||||
("feedback-custom", "Aide · formulaire publié depuis le menu (tous les types de champs)", form =>
|
||||
{
|
||||
Choose(form, FeedbackKinds.Bug);
|
||||
form.Title = "Le son disparaît en coopératif";
|
||||
Field(form, "platform").CheckOptions[1].IsChecked = true;
|
||||
Field(form, "rating10").Rating = 7;
|
||||
Field(form, "version_seen").Text = "0.9.3";
|
||||
Field(form, "again").IsChecked = true;
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
("feedback-done", "Aide · demande envoyée (une capture refusée par le serveur)", async form =>
|
||||
{
|
||||
Choose(form, FeedbackKinds.Other);
|
||||
@@ -69,7 +82,8 @@ internal sealed class FeedbackScenes : ISceneSet
|
||||
await capture.ShootAsync(id + Capture.Suffix(theme), title, async () =>
|
||||
{
|
||||
_fixture.ApplyTheme(theme);
|
||||
var form = NewForm();
|
||||
var form = NewForm(custom: id.StartsWith("feedback-custom", StringComparison.Ordinal));
|
||||
await form.FormsLoad;
|
||||
await apply(form);
|
||||
if (form.HasError && id != "feedback-bug-errors") capture.Problem($"Formulaire « Aide » : {form.ErrorMessage}");
|
||||
if (form.HasAttachmentError) capture.Problem($"Capture d'écran refusée : {form.AttachmentError}");
|
||||
@@ -79,7 +93,8 @@ internal sealed class FeedbackScenes : ISceneSet
|
||||
}
|
||||
}
|
||||
|
||||
private FeedbackFormViewModel NewForm()
|
||||
/// <param name="custom">Formulaires « publiés depuis le menu » (<see cref="SampleForms"/>) au lieu de ceux d'origine.</param>
|
||||
private FeedbackFormViewModel NewForm(bool custom = false)
|
||||
{
|
||||
var game = _fixture.MainGame;
|
||||
var target = new FeedbackTarget(game.GameId, game.ShownName, "1.4.2", _fixture.LauncherVersion);
|
||||
@@ -91,7 +106,16 @@ internal sealed class FeedbackScenes : ISceneSet
|
||||
Os = "Windows 11 64 bits (10.0.26100)",
|
||||
Language = "fr-FR"
|
||||
};
|
||||
return new FeedbackFormViewModel(target, new SampleSender(), tech, new SampleScreenshots());
|
||||
return new FeedbackFormViewModel(target, new SampleSender(), tech, new SampleScreenshots(), custom ? new SampleForms() : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formulaires publiés de démonstration (<see cref="SampleData.SampleFeedbackForms"/>, révision 3). Aucun réseau ni
|
||||
/// signature : seul l'affichage compte ici.
|
||||
/// </summary>
|
||||
private sealed class SampleForms : IFeedbackFormsSource
|
||||
{
|
||||
public Task<FeedbackFormsDocument> LoadAsync(bool refresh, CancellationToken ct) => Task.FromResult(SampleData.SampleFeedbackForms());
|
||||
}
|
||||
|
||||
private static void FillBug(FeedbackFormViewModel form)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Windows;
|
||||
using GameLauncher.Contracts.Maintenance;
|
||||
using GameLauncher.Core.Models;
|
||||
using GameLauncher.Core.SelfUpdate;
|
||||
using GameLauncher.Core.Utils;
|
||||
@@ -23,7 +24,8 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
|
||||
public PlayerScenes(Fixture fixture) => _fixture = fixture;
|
||||
|
||||
private sealed record MainState(string Id, string Title, Action<GameViewModel, Fixture> Apply);
|
||||
/// <param name="Launcher">Facultatif : état commun à tous les jeux, posé après celui du jeu (mode maintenance…).</param>
|
||||
private sealed record MainState(string Id, string Title, Action<GameViewModel, Fixture> Apply, Action<LauncherViewModel>? Launcher = null);
|
||||
|
||||
/// <summary>États de la fenêtre principale (textes repris de GameViewModel).</summary>
|
||||
private static readonly MainState[] MainStates =
|
||||
@@ -104,7 +106,22 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
"Les informations publiées sur le serveur n'ont pas une signature valide : elles sont ignorées. Vous pouvez tout de même jouer.",
|
||||
DialogKind.Error);
|
||||
}),
|
||||
new("main-checking", "Fenêtre principale · démarrage (vérification)", (g, f) => { })
|
||||
new("main-checking", "Fenêtre principale · démarrage (vérification)", (g, f) => { }),
|
||||
// Mode maintenance (publié depuis le menu d'administration) : palette rouge / orange, carte au centre, tout bloqué
|
||||
// sauf la désinstallation. Avec une précision de l'administrateur, puis sans (jeu non installé).
|
||||
new("main-maintenance", "Fenêtre principale · mode maintenance (avec message)", (g, f) =>
|
||||
{
|
||||
Installed(g, f, "1.4.2", "1.4.2");
|
||||
State(g, LauncherState.Ready, "Prêt à jouer", "Version 1.4.2");
|
||||
}, l => l.ApplyMaintenance(SampleData.SampleMaintenance(
|
||||
"Mise à jour des serveurs de jeu et sauvegarde des mondes.\nRetour prévu vers 18 h : merci de votre patience !"))),
|
||||
new("main-maintenance-plain", "Fenêtre principale · mode maintenance (sans message, jeu non installé)", (g, f) =>
|
||||
{
|
||||
Poke.Set(g, "LatestVersion", "1.4.2");
|
||||
Notes(g);
|
||||
State(g, LauncherState.NotInstalled, "Prêt à installer",
|
||||
$"Téléchargement : {Format.Bytes(DownloadSize)} • Espace requis : {Format.Bytes(InstalledSize)}");
|
||||
}, l => l.ApplyMaintenance(SampleData.SampleMaintenance(null)))
|
||||
};
|
||||
|
||||
public IEnumerable<SceneInfo> List(IReadOnlyList<string?> themes)
|
||||
@@ -132,7 +149,7 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
{
|
||||
var suffix = Capture.Suffix(theme);
|
||||
foreach (var state in MainStates)
|
||||
await MainAsync(capture, state.Id + suffix, state.Title, _fixture.Catalog, theme, state.Apply);
|
||||
await MainAsync(capture, state.Id + suffix, state.Title, _fixture.Catalog, theme, state.Apply, state.Launcher);
|
||||
|
||||
if (_fixture.MultiCatalog is { } multi)
|
||||
await MainAsync(capture, "main-multi" + suffix, "Fenêtre principale · plusieurs jeux (liste à gauche)", multi, theme,
|
||||
@@ -153,12 +170,13 @@ internal sealed class PlayerScenes : ISceneSet
|
||||
// ------------------------------------------------------------------ Fenêtre principale
|
||||
|
||||
private Task MainAsync(Capture capture, string id, string title, LauncherCatalog catalog, string? theme,
|
||||
Action<GameViewModel, Fixture> apply) =>
|
||||
Action<GameViewModel, Fixture> apply, Action<LauncherViewModel>? applyLauncher = null) =>
|
||||
capture.ShootAsync(id, title, () =>
|
||||
{
|
||||
var launcher = _fixture.CreateLauncher(catalog, new RecordingDialogs(capture.Problem));
|
||||
var game = launcher.SelectedGame ?? throw new InvalidOperationException("Aucun jeu dans launcher.json.");
|
||||
apply(game, _fixture);
|
||||
applyLauncher?.Invoke(launcher);
|
||||
Poke.Call(game, "RefreshCommands");
|
||||
|
||||
var window = new MainWindow();
|
||||
|
||||
+50
-97
@@ -1,131 +1,84 @@
|
||||
# Launcher 3.1.0
|
||||
# Launcher 3.3.1
|
||||
LAUNCHER - NOTES DE MISE À JOUR
|
||||
Captures d'écran et liens signalés - Mise à jour du 9 novembre 2026
|
||||
Correctif du mode maintenance - Mise à jour du 10 novembre 2026
|
||||
==================================================================
|
||||
|
||||
Depuis le bouton « Aide », vous pouvez maintenant joindre jusqu'à 3 captures d'écran à votre demande. Chaque image est contrôlée et débarrassée de ses métadonnées (ni position GPS, ni appareil, ni date) avant l'envoi.
|
||||
Un launcher resté ouvert pendant la re-signature du trousseau ne voyait pas la maintenance activée ensuite : chaque relecture (toutes les 2 minutes) la refusait jusqu'au redémarrage. Il prend désormais le nouveau trousseau en compte tout seul, et le relit aussi régulièrement quand il reste ouvert longtemps.
|
||||
|
||||
Dans le menu d'administration, une demande contenant un lien est signalée par un avertissement, et le journal d'audit affiche des libellés lisibles. La fenêtre « Aide » reste centrée et défile avec un ascenseur. Le serveur est à redéployer, avec un bloc nginx à ajouter, avant de publier cette version.
|
||||
Aucune action sur le serveur. Le launcher se met à jour automatiquement.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
EN BREF
|
||||
------------------------------------------------------------------
|
||||
- NOUVEAU : captures d'écran jointes à une demande (3 au plus,
|
||||
fichier ou Ctrl+V) : vraie image exigée, PNG ou JPEG seulement,
|
||||
sans métadonnées.
|
||||
- NOUVEAU : captures affichées dans la fiche d'une demande
|
||||
(administration), revérifiées avant l'affichage.
|
||||
- NOUVEAU : avertissement quand une demande contient un lien ; liens
|
||||
désamorcés, jamais cliquables.
|
||||
- AMÉLIORÉ : journal d'audit avec des libellés lisibles (« Demande de
|
||||
joueur reçue »…) et une catégorie « Demandes des joueurs ».
|
||||
- AMÉLIORÉ : fenêtre « Aide » centrée, formulaire long avec un
|
||||
ascenseur.
|
||||
- SÉCURITÉ : image contrôlée par le launcher et par le serveur ;
|
||||
envoi autorisé par un jeton propre à la demande.
|
||||
- SERVEUR : à redéployer, avec un nouveau bloc nginx.
|
||||
- QUALITÉ : 30 nouveaux tests automatisés (342 au total).
|
||||
- CORRIGÉ : mode maintenance activé après la re-signature du
|
||||
trousseau non vu par un launcher déjà ouvert (redémarrage
|
||||
nécessaire en 3.3.0).
|
||||
- AMÉLIORÉ : trousseau retéléchargé quand un contenu est signé par
|
||||
une clé connue mais pas encore autorisée pour ce contenu.
|
||||
- SÉCURITÉ : trousseau relu au plus toutes les 30 minutes : un
|
||||
appareil retiré n'est plus accepté par un launcher laissé ouvert.
|
||||
- QUALITÉ : 3 nouveaux tests automatisés (370 au total).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
CAPTURES D'ÉCRAN
|
||||
CORRECTIF
|
||||
------------------------------------------------------------------
|
||||
- Nouvelle section « Captures d'écran (facultatif) » dans le
|
||||
formulaire du bouton « Aide », pour tous les types de demande.
|
||||
- « Ajouter une image… » : PNG, JPEG, BMP, GIF ou WebP (30 Mo au
|
||||
plus).
|
||||
- « Coller (Ctrl+V) » : image du presse-papiers, après Impr. écran ou
|
||||
Win+Maj+S ; Ctrl+V fonctionne aussi depuis un champ de texte.
|
||||
- Miniature de chaque image, avec une croix pour la retirer avant
|
||||
l'envoi.
|
||||
- Image reconnue à son contenu (un fichier renommé est refusé),
|
||||
réduite à 3 840 px au plus et réencodée en PNG ou JPEG sans
|
||||
métadonnées : ni position GPS, ni appareil, ni date.
|
||||
- Le nom du fichier n'est jamais envoyé.
|
||||
- Si une capture ne peut pas être envoyée, un avertissement le
|
||||
précise : la demande, elle, est bien enregistrée.
|
||||
- Cause : le launcher gardait en mémoire le trousseau lu au
|
||||
démarrage. Il ne le retéléchargeait que pour une clé inconnue,
|
||||
pas pour une clé connue qui vient de recevoir une autorisation
|
||||
(« mode maintenance » ajoutée à l'étape 3 decies).
|
||||
- Effet en 3.3.0 : « la clé … n'est pas autorisée à signer ce
|
||||
contenu » dans le journal à chaque relecture, maintenance
|
||||
ignorée jusqu'au redémarrage.
|
||||
- Désormais : nouveau téléchargement du trousseau, au plus une
|
||||
fois par minute ; la maintenance s'affiche à la relecture
|
||||
suivante (2 minutes au plus).
|
||||
- Concerne aussi les formulaires « Aide », le catalogue et les
|
||||
versions des jeux signés par un appareil qui vient d'être
|
||||
autorisé.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
PAGE « DEMANDES DES JOUEURS » (ADMINISTRATION)
|
||||
VÉRIFICATIONS PÉRIODIQUES DU LAUNCHER
|
||||
------------------------------------------------------------------
|
||||
- Liste : icône « image » pour une demande avec des captures, icône
|
||||
« alerte » pour une demande contenant un lien.
|
||||
- Fiche : captures d'écran en miniature, « Agrandir » / « Réduire » ;
|
||||
chaque image est revérifiée avant l'affichage et rien n'est écrit
|
||||
sur le PC.
|
||||
- Liens : bandeau d'avertissement en haut de la fiche, liens
|
||||
désamorcés (hxxps[://]site[.]com) et champs concernés surlignés ;
|
||||
jamais cliquables.
|
||||
- Formes reconnues : http, https, ftp, file, protocoles dangereux
|
||||
(javascript:, data:…), www, domaines nus (site.ru, discord.gg/…),
|
||||
adresses IP, chemins réseau, e-mails, formes déguisées (hxxp, [.],
|
||||
(dot), caractères pleine chasse).
|
||||
- Mode maintenance : au démarrage (4 s au plus), puis toutes les
|
||||
2 minutes (1 minute pendant une maintenance), et aussitôt après
|
||||
une publication depuis le menu de ce PC.
|
||||
- Versions des jeux (manifest) : au démarrage, puis toutes les
|
||||
30 minutes (updateCheckIntervalMinutes).
|
||||
- Trousseau : au démarrage ; après un échec réseau (1 minute) ;
|
||||
clé inconnue ou non autorisée (1 minute au plus) ; au plus
|
||||
toutes les 30 minutes (nouveau).
|
||||
- Catalogue : au démarrage seulement.
|
||||
- Mise à jour du launcher : au démarrage, et bouton « Rechercher »
|
||||
des paramètres.
|
||||
- Formulaires « Aide » : à l'ouverture de la fenêtre « Aide ».
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
JOURNAL D'AUDIT ET FENÊTRE « AIDE »
|
||||
SÉCURITÉ
|
||||
------------------------------------------------------------------
|
||||
- Libellés lisibles : Demande de joueur reçue, consultée, traitée,
|
||||
supprimée ; Capture d'écran reçue ; Anciennes demandes supprimées ;
|
||||
Anciens mois du journal supprimés ; Trousseau de signature publié ;
|
||||
Version en ligne signée.
|
||||
- Nouvelles catégories : « Demandes des joueurs » et « Signature
|
||||
(trousseau) ».
|
||||
- Fenêtre « Aide » centrée à chaque étape ; formulaire limité à une
|
||||
hauteur de base, avec un ascenseur au-delà.
|
||||
- La molette fait défiler le formulaire et ne change jamais une liste
|
||||
déroulante fermée.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
SÉCURITÉ ET VIE PRIVÉE
|
||||
------------------------------------------------------------------
|
||||
- Le serveur n'accepte que de vrais PNG ou JPEG : structure complète
|
||||
vérifiée, rien après la fin de l'image, 16 à 4 096 px, 4 Mo au plus.
|
||||
- Métadonnées retirées par le launcher puis par le serveur.
|
||||
- Jeton d'envoi propre à la demande, valable 15 minutes, pour le
|
||||
nombre de captures annoncé seulement.
|
||||
- Plafond global porté à 1 Go (captures comprises).
|
||||
- Capture affichée dans le menu seulement après un nouveau contrôle,
|
||||
jamais écrite sur le disque.
|
||||
- Aucune adresse IP enregistrée avec une capture ni dans l'audit de
|
||||
son envoi ; nginx n'écrit pas ces requêtes dans ses journaux.
|
||||
- Captures supprimées avec leur demande (suppression ou purge).
|
||||
- Statut, note et suppression restent des requêtes signées.
|
||||
- Chaque trousseau retéléchargé passe par les mêmes contrôles :
|
||||
signature de la clé racine, numéro jamais en baisse, minimum
|
||||
keyringMinSequence ; un ancien trousseau resservi est refusé.
|
||||
- Serveur injoignable : dernier trousseau vérifié conservé.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
SERVEUR
|
||||
------------------------------------------------------------------
|
||||
- À redéployer AVANT de publier le launcher : server/deploy/
|
||||
LISEZMOI.md, étape 8, puis étape 3 octies.
|
||||
- nginx : ajouter le troisième bloc
|
||||
« location ^~ /api/v1/feedback/attachments/ » (nginx-api.conf).
|
||||
- Si Feedback:MaxTotalMegabytes est fixé dans
|
||||
appsettings.Production.json, le porter à 1024.
|
||||
- Sans redéploiement, les demandes arrivent, mais sans leurs
|
||||
captures (le joueur est prévenu).
|
||||
- Aucun changement.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
DÉVELOPPEMENT
|
||||
------------------------------------------------------------------
|
||||
- Contrats : FeedbackLinks (détection des liens), FeedbackImages
|
||||
(contrôle des PNG/JPEG et retrait des métadonnées, sans
|
||||
bibliothèque).
|
||||
- Serveur : envoi et lecture des captures (FeedbackModule), jetons
|
||||
d'envoi (FeedbackGuard), stockage à côté des demandes
|
||||
(FeedbackStore).
|
||||
- Launcher : ScreenshotSource (fichier ou presse-papiers, réencodage
|
||||
sans métadonnées).
|
||||
- Outil ui-snapshot : fenêtre « Aide » et fiche d'une demande
|
||||
capturées ; section « Tenir l'outil à jour » dans son LISEZMOI.
|
||||
- Tests : FeedbackContentTests.cs (nouveau), FeedbackModuleTests.cs,
|
||||
FeedbackPanelTests.cs.
|
||||
- Documentation : README.md, docs/ADMIN-ARCHITECTURE.md
|
||||
(§ 11 quinquies) et server/deploy/LISEZMOI.md mis à jour.
|
||||
- Core : ContentTrustService (KeyChangeRetryDelay, KeyringMaxAge,
|
||||
KeyMayBeNewer).
|
||||
- Tests : ContentTrustTests (2), MaintenanceServiceTests (1).
|
||||
- Documentation : README, docs/ADMIN-ARCHITECTURE.md,
|
||||
server/deploy/LISEZMOI.md (étapes 3 quinquies et 3 decies).
|
||||
|
||||
|
||||
La sécurité des mises à jour du launcher ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
|
||||
Reference in new issue
Block a user