Compare commits

...
64 Commits
Author SHA1 Message Date
nicolas 927df6c2b2 Version 3.4.1 2026-11-10 16:55:01 +01:00
nicolas 197f5186d0 UiSnapshot : captures en WebP (--format webp, --quality)
- --format png|webp (png par défaut), --quality 1-100 (85 par défaut,
  100 = sans perte) ; transparence conservée, pages .p2.webp comprises.
- Encodage WebP par SkiaSharp 3.119.1 (Microsoft, MIT) : WIC lit le WebP sans
  savoir l'écrire. Outil de développement uniquement.
- index.html et report.md reconnaissent PNG et WebP ; une série complète
  efface aussi les anciens .webp.
- LISEZMOI : captures pour le site vitrine ; README §5 et §9 mis à jour.
2026-11-10 16:50:13 +01:00
nicolas 98cf17c294 Merge pull request 'Web/1.0.0' (#143) from web/1.0.0 into master
Reviewed-on: #143
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 16:29:33 +01:00
nicolas 4da86bcc6a README : site vitrine (§9)
Ligne GameLauncher.Website dans la structure de la solution, nouvelle section
§9 : pages, thèmes, isolation de la Launcher API, téléchargement vérifié,
sécurité HTTP, service durci, liseuse Markdown, tests, captures à mettre à jour.
2026-11-10 16:17:16 +01:00
nicolas 52761f53b6 Site vitrine : publication et déploiement sur le VPS
- publish-site.bat : exe autonome linux-x64, archive et empreinte SHA-256.
- installer-site.sh :
  - archive vérifiée par son empreinte ;
  - binaires dans /opt/lanisys-site (propriétaire root) ;
  - appsettings.Production.json conservé d'une version à l'autre ;
  - sorties réseau autorisées recalculées (serveur du launcher + DNS) dans
    sortie.conf ;
  - retour à la version précédente si le site ne répond pas.
- lanisys-site.service :
  - compte temporaire (DynamicUser), système en lecture seule, aucun privilège ;
  - dossier personnel /var/lib/lanisys-site (700), nécessaire à .NET pour
    vérifier un certificat HTTPS sous Debian 11 ;
  - connexions sortantes limitées au serveur du launcher ;
  - mémoire limitée.
- nginx-site.conf :
  - redirection http → https et domaine nu → www ;
  - HTTP/2 compatible nginx 1.18 (listen … http2) ;
  - TLS 1.2 et 1.3, renouvellement Let's Encrypt par webroot ;
  - lectures seulement, fichiers cachés jamais servis, gzip, limite de
    requêtes (nginx-site-zones.conf).
- LISEZMOI.md : pourquoi un VPS séparé, domaine, publication, installation,
  nginx et HTTPS, mentions légales, journaux, mise à jour, écriture des
  actualités, ajout d'un jeu, test en local, dépannage, fichiers sur le VPS.
  Debian 11, 12 ou 13.
2026-11-10 16:16:58 +01:00
nicolas cf75a22f19 Tests : site vitrine
Les sources du site (Services, Options) et la clé publique sont compilées
dans le projet de tests, sans référence au projet du site : une référence
copierait l'appsettings.json du site à la place de celui de la Launcher API
dans le dossier des tests.

Tests :
- launcher-update.json : signature valide, contenu modifié, autre clé,
  exécutable hors du serveur (autre hôte, http, autre port, paramètres),
  produit inattendu, clé intégrée ;
- manifest : bornes et nettoyage des notes, autre jeu, fichier illisible ;
- réseau : adresses https uniquement, lecture bornée, message d'erreur
  détaillé (certificat refusé) ;
- Markdown : HTML brut, liens javascript:, data: et //, injection
  d'attribut, images externes, syntaxe prise en charge ;
- actualités : en-tête, noms de fichiers invalides, brouillons.
2026-11-10 16:16:26 +01:00
nicolas 009c84de9f Site vitrine : premières actualités
- Ouverture du site.
- Exovival Zombie Reborn 1.8 : zombies rôdeurs, ouïe des zombies, armes,
  soins.
- Launcher 3.4 : versions prioritaires.
2026-11-10 16:15:28 +01:00
nicolas 9f9b9b9d4d Site vitrine : images, polices et icônes
- Polices auto-hébergées Sora, Barlow et Barlow Condensed (WOFF2), avec leurs
  licences OFL : aucune requête vers un service externe.
- Captures d'Exovival : couloir de l'école, encre UV, images des cassettes VHS
  sans les dates qui révèlent un code, et vues de l'abri en développement. Le
  tout en WebP.
- Captures du launcher (docs/preview*.png et UiSnapshot) en WebP.
- Favicon et icônes reprises du launcher, images de partage (Open Graph) du
  launcher et d'Exovival.
2026-11-10 16:14:55 +01:00
nicolas 12ab8377c9 Site vitrine : pages, thèmes par univers et script
Pages :
- accueil ;
- /launcher : présentation des fonctions joueur, téléchargement vérifié
  (version, taille, empreinte SHA-256), première installation, notes de la
  dernière version, lien vers les publications Gitea, questions fréquentes ;
- /jeux et /jeux/exovival-zombie-reborn : histoire, contenu, cassettes VHS,
  extension « L'abri » en développement, configuration ;
- /jeux/<jeu>/notes-de-version : toutes les versions ;
- page générique des jeux en préparation (projet multijoueur) ;
- actualités, mentions légales, confidentialité, erreurs 404 et 500.

Thèmes par page (data-theme) :
- launcher : violet et cyan, police Sora ;
- exovival : noir et vert, Barlow Condensed ; en-tête « lampe UV » qui révèle
  le message de Léa sous le pointeur ;
- multi : ambre.

Notes de version affichées comme dans le launcher (titre, sous-titre,
introduction, sections, étiquettes), textes toujours encodés.

Bouton « Nous contacter » : simple lien mailto. Aucun formulaire.

Responsive jusqu'au mobile, focus clavier visible, mouvement réduit respecté.
Script sans dépendance (menu mobile, copie de l'empreinte, lampe UV),
compatible avec la CSP (aucun script en ligne).
2026-11-10 16:14:00 +01:00
nicolas 5aa22df409 Site vitrine : nouveau projet GameLauncher.Website (ASP.NET Core Razor Pages)
Projet ajouté à la solution (dossier « website »), sans paquet NuGet tiers.
Références : GameLauncher.Contracts et GameLauncher.Core.

Isolé de la Launcher API : le site ne l'appelle jamais. En arrière-plan, côté
serveur, il lit seulement les fichiers publics launcher-update.json et
manifest.json :
- toutes les 10 minutes, avec requêtes conditionnelles (ETag, Last-Modified) ;
- https uniquement, aucune redirection suivie, taille et délai bornés.
Les pages ne déclenchent aucune requête sortante.

launcher-update.json :
- signature vérifiée avec la clé update-signing.pub, intégrée à l'exe du site,
  par le même code que le launcher (UpdateSignature) ;
- l'exécutable doit être sur le même serveur, en https ;
- produit attendu facultatif (Site:Launcher:Product) ;
- fichier refusé : téléchargement retiré ; serveur injoignable : dernière
  version valide conservée.

Notes de version : bornées et nettoyées comme dans le launcher
(ChangelogLimits.Normalize). Sections réservées à l'équipe masquées pour le
launcher (Site:Launcher:HiddenSections).

Certificat HTTPS du serveur du launcher : même règle que par défaut ; en cas de
refus, le journal détaille la chaîne de certificats (TlsDiagnostics) et la
cause complète de l'erreur réseau.

Actualités en Markdown (Content/News) : liseuse maison SafeMarkdown.
- tout le texte est encodé ;
- liens http(s), mailto et chemins du site seulement ;
- images du site (/img/) seulement.

Sécurité HTTP :
- CSP stricte (aucun script en ligne, aucune ressource externe), HSTS,
  nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, COOP/CORP ;
- GET/HEAD seulement (405 sinon) ;
- limite de requêtes par IP ;
- aucun cookie, clés de protection des données en mémoire ;
- en-tête Server retiré.

Autres :
- réglages contrôlés au démarrage (SiteOptionsValidator) ;
- dates en français sans dépendance à ICU (InvariantGlobalization) ;
- robots.txt, sitemap.xml, /healthz réservé à la machine locale.
2026-11-10 16:12:47 +01:00
nicolas 3df009912b Git : fins de ligne LF pour website/deploy/*.conf
Les configurations nginx du site vitrine sont copiées telles quelles sur le VPS :
même règle que server/deploy/*.conf.
2026-11-10 16:10:33 +01:00
nicolas 89570947aa Merge pull request 'Dev/3.4.0' (#142) from dev/3.4.0 into master
Reviewed-on: #142
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 13:49:24 +01:00
nicolas 14b2167218 Launcher 3.4.0 : mises à jour prioritaires
- Version 3.3.1 → 3.4.0
- Notes de version (notes-launcher.txt, RELEASE-3.4.0.md)
- README §7 : section « Version prioritaire », sécurité, publication
2026-11-10 13:35:25 +01:00
nicolas 2e4d2ee76e UiSnapshot : scène launcher-update-priority 2026-11-10 13:34:37 +01:00
nicolas 7d9032fe20 Publication : options --priority et --clear-priority
- LauncherPublisher publish : --priority (confirmation o/N), --clear-priority,
  --yes ; priorité reprise du launcher-update.json précédent s'il est signé
- Ligne « Priorité » dans publish et verify
- publish-launcher-update.bat : paramètres --priority, --clear-priority,
  --help (shift /1 pour garder %~dp0)
2026-11-10 13:34:13 +01:00
nicolas ccd6b1a430 Interface : pastille « Prioritaire » dans la fenêtre de mise à jour du launcher
Sous-titre dédié précisant que le réglage du joueur ne change pas.
2026-11-10 13:33:02 +01:00
nicolas e4a21c6f5d Launcher : version prioritaire installée même si la MAJ auto est désactivée
- Recherche au démarrage même option désactivée ; seule une version
  prioritaire est alors installée, self-update.json jamais modifié
- Erreur de téléchargement d'une version prioritaire : retentée au
  démarrage suivant
- Version prioritaire non installée : avertissement dans le bandeau et
  statut orange dans les Paramètres du launcher
2026-11-10 13:32:42 +01:00
nicolas 1a7f45c38b Tests : versions prioritaires du launcher (8 tests, 12 cas)
Signature, compatibilité sans priorité, launchers forcés ou non,
validation, reprise / retrait par l'outil de publication,
self-update.json sans champ de priorité.
2026-11-10 13:32:05 +01:00
nicolas b48d4222db Core : version prioritaire du launcher dans launcher-update.json (signée)
- LauncherRelease.PriorityVersion : dernière version prioritaire publiée,
  dans le payload signé (ignorée par les launchers < 3.4.0)
- IsPriorityRelease, IsPriorityFor(), TryGetPriorityVersion()
- ResolvePriorityVersion() : marquer, reprendre ou retirer la priorité
  d'une publication à l'autre
- Validate() refuse une version prioritaire illisible ou supérieure à la
  version publiée
- LauncherUpdateCheck.IsPriority
2026-11-10 13:31:36 +01:00
nicolas 51eecbb57a Merge pull request 'Dev/3.3.1' (#139) from dev/3.3.1 into master
Reviewed-on: #139
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 12:30:21 +01:00
nicolas edd2633455 chore(release): launcher 3.3.1 2026-11-10 12:18:55 +01:00
nicolas 0e26e6eb11 docs: rafraîchissement du trousseau (3.3.1) 2026-11-10 12:18:22 +01:00
nicolas 916b53446a fix(trust): retélécharger le trousseau quand une clé connue n'est pas autorisée, âge max 30 min 2026-11-10 12:17:50 +01:00
nicolas 0e371518b5 Merge pull request 'Dev/3.3.0' (#138) from dev/3.3.0 into master
Reviewed-on: #138
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 11:50:25 +01:00
nicolas 153e498345 Tests et documentation du mode maintenance
- 11 nouveaux tests (367 au total) ; SigningPanelTests : ligne « Mode maintenance » en dernier
- README, ADMIN-ARCHITECTURE (§ 7, § 11 septies, § 13), notes-launcher 3.3.0
2026-11-10 11:40:43 +01:00
nicolas 1e9f49a733 ui-snapshot : scènes du mode maintenance
main-maintenance, main-maintenance-plain, admin-page-maintenance, admin-page-maintenance-edit ;
routes v1/maintenance simulées ; ligne « Mode maintenance » dans admin-page-signing
2026-11-10 11:37:50 +01:00
nicolas 5f6c667030 Launcher 3.3.0 : mode maintenance
- Thème rouge / orange, carte « Maintenance en cours » au centre, bouton « MAINTENANCE »
- Tout est bloqué sauf la désinstallation et le menu d'administration
  (commandes désactivées + refus à l'entrée de chaque action)
- État lu avant la recherche des mises à jour (4 s au plus), puis relu périodiquement
- Page « Maintenance » et bandeau du tableau de bord (Admin.xaml), icône TrafficCone
- Version 3.3.0
2026-11-10 11:37:28 +01:00
nicolas 93dd844221 Menu admin : page « Maintenance »
- Activer (confirmation), modifier le message, terminer ; contrôle du trousseau des joueurs
- Le launcher de ce PC relit l'état aussitôt (NotifyMaintenanceChanged)
- Tableau de bord : bandeau « Maintenance en cours »
- Page « Signature » : ligne « Mode maintenance » ; journal d'audit : catégorie dédiée
2026-11-10 11:36:01 +01:00
nicolas 5ad8025946 Core : lecture et vérification du mode maintenance ; client de l'API
- MaintenanceService : lecture au démarrage puis toutes les 1 à 2 min,
  signature vérifiée (contentSigning), retour en arrière refusé, hors ligne = pas de maintenance
- ContentTrustService.VerifyMaintenanceAsync
- AdminSession : GetMaintenanceAsync, PublishMaintenanceAsync
2026-11-10 11:34:55 +01:00
nicolas 080000e13c Déploiement : bloc nginx maintenance.json et étape 3 decies 2026-11-10 11:34:30 +01:00
nicolas 3422b92f81 Serveur : module Maintenance (GET / PUT v1/maintenance)
- Publication signée (maintenance.manage + document signé par la clé de la session)
- Révision attendue (409), début inchangé pendant une maintenance
- Fichier public maintenance.json à côté du trousseau, historique de 20 révisions
- Audit maintenance.enable / update / disable
- GET v1/signing renvoie la signature du mode maintenance
2026-11-10 11:34:12 +01:00
nicolas 683cc7a9dc Contrats : document du mode maintenance, permission et autorisation de signature
- MaintenanceDocument / MaintenanceState / MaintenancePublishRequest
- MaintenanceRules : règles communes (message 300 car., 6 lignes, sans lien, condensé signé)
- Permission maintenance.manage (Propriétaire, Administrateur sur installation neuve)
- Type de contenu et autorisation du trousseau « maintenance »
- Route v1/maintenance ; SigningState.Maintenance
2026-11-10 11:33:04 +01:00
nicolas 7bc229de53 Merge pull request 'Dev/3.2.1' (#136) from dev/3.2.1 into master
Reviewed-on: #136
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 10:03:15 +01:00
nicolas 83e3d61634 Version 3.2.1 et notes de mise à jour 2026-11-10 10:01:53 +01:00
nicolas b088c6caa7 Docs et notes 3.2.0 : page « Signature » 2026-11-10 09:48:15 +01:00
nicolas 658a1cce72 ui-snapshot : formulaires dans la page « Signature » 2026-11-10 09:47:47 +01:00
nicolas bac8ebeb55 Admin : formulaires des demandes dans la page « Signature » 2026-11-10 09:47:26 +01:00
nicolas 76eaa48342 Signature : état de signature des formulaires des demandes (serveur et contrat) 2026-11-10 09:46:55 +01:00
nicolas 97df0ba721 Merge pull request 'Dev/3.2.0' (#135) from dev/3.2.0 into master
Reviewed-on: #135
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 09:25:36 +01:00
nicolas f0f403ce66 Version 3.2.0 et notes de mise à jour 2026-11-10 09:16:42 +01:00
nicolas 977e2ae892 Launcher : fenêtre « Aide » avec formulaires publiés, nouveaux champs, icônes importées, captures par type 2026-11-10 09:16:32 +01:00
nicolas 1913fcdb01 Docs : formulaires des demandes modifiables (§ 11 sexies, README) 2026-11-10 09:16:06 +01:00
nicolas f25051c627 ui-snapshot : page « Formulaires des demandes » et formulaires publiés 2026-11-10 09:15:32 +01:00
nicolas 82ad4ec500 Tests : formulaires des demandes (contrats, serveur, menu, trousseau) 2026-11-10 09:15:14 +01:00
nicolas a4ef606ff7 Admin : page « Formulaires des demandes » et demandes affichées avec leur formulaire 2026-11-10 09:14:52 +01:00
nicolas fcb27d40b1 Launcher : fenêtre « Aide » avec formulaires publiés, nouveaux champs, icônes importées, captures par type 2026-11-10 09:13:34 +01:00
nicolas 45140ba78a Déploiement : blocs nginx des formulaires (lecture publique, publication 300 Ko) 2026-11-10 09:12:38 +01:00
nicolas 5457e66144 Serveur : publication signée des formulaires, validation des demandes par révision 2026-11-10 09:12:18 +01:00
nicolas 05c5e24327 Signature : type de contenu feedback-forms, autorisation « forms » du trousseau, permission feedback.forms 2026-11-10 09:11:31 +01:00
nicolas d920702353 Contrats : formulaires des demandes signés, catégories dynamiques et icônes .ico 2026-11-10 09:10:37 +01:00
nicolas e96edfce4f Merge pull request 'Dev/3.1.0' (#133) from dev/3.1.0 into master
Reviewed-on: #133
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-09 16:43:55 +01:00
nicolas 11721e9260 Version 3.1.0 : captures d'écran, liens signalés, journal d'audit lisible 2026-11-09 16:37:42 +01:00
nicolas 5d9f413f57 UiSnapshot : fenêtre « Aide » et fiche des demandes des joueurs 2026-11-09 16:29:39 +01:00
nicolas cde3b337e7 Aide : fenêtre centrée et formulaire avec ascenseur (taille de base réduite) 2026-11-09 16:19:47 +01:00
nicolas 1c198f1204 Docs : captures d'écran, avertissement sur les liens, signature des requêtes 2026-11-09 16:03:25 +01:00
nicolas 2d26416a7a Tests : liens, captures d'écran et libellés du journal d'audit 2026-11-09 16:02:59 +01:00
nicolas 24de56cdcb Demandes des joueurs : avertissement sur les liens et affichage des captures 2026-11-09 16:01:56 +01:00
nicolas 8a7e51fa07 Aide : ajout de captures d'écran par fichier ou Ctrl+V, réencodées sans métadonnées 2026-11-09 16:01:32 +01:00
nicolas e18ebf818f Client API : envoi des captures (joueur) et lecture des captures (menu) 2026-11-09 16:00:47 +01:00
nicolas 821d31ce30 Déploiement : bloc nginx des captures d'écran et mise à jour de l'étape 3 octies 2026-11-09 16:00:33 +01:00
nicolas db4c874e13 Serveur : captures d'écran des demandes (jeton d'envoi, contrôle de l'image, plafond 1 Go) 2026-11-09 16:00:16 +01:00
nicolas 64fb944038 Contrats : détection des liens et contrôle des captures d'écran (PNG/JPEG sans métadonnées) 2026-11-09 15:59:27 +01:00
nicolas bc3bf4fbba Audit : libellés lisibles pour les actions des demandes des joueurs 2026-11-09 15:58:55 +01:00
nicolas ade18e84d2 Merge pull request 'Dev/3.0.0' (#131) from dev/3.0.0 into master
Reviewed-on: #131
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-09 15:06:00 +01:00
194 changed files with 15400 additions and 459 deletions

No files matched your search

+1
View File
@@ -68,3 +68,4 @@
*.sh text eol=lf
*.service text eol=lf
server/deploy/*.conf text eol=lf
website/deploy/*.conf text eol=lf
+24
View File
@@ -33,6 +33,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
tools\ui-snapshot.bat = tools\ui-snapshot.bat
tools\sign-keyring.bat = tools\sign-keyring.bat
tools\publish-server.bat = tools\publish-server.bat
tools\publish-site.bat = tools\publish-site.bat
server-example\web.config = server-example\web.config
EndProjectSection
EndProject
@@ -70,6 +71,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "website", "website", "{8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Website", "website\GameLauncher.Website\GameLauncher.Website.csproj", "{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "deploy", "deploy", "{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14}"
ProjectSection(SolutionItems) = preProject
website\deploy\LISEZMOI.md = website\deploy\LISEZMOI.md
website\deploy\installer-site.sh = website\deploy\installer-site.sh
website\deploy\lanisys-site.service = website\deploy\lanisys-site.service
website\deploy\nginx-site.conf = website\deploy\nginx-site.conf
website\deploy\nginx-site-zones.conf = website\deploy\nginx-site-zones.conf
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -182,6 +196,14 @@ Global
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|Any CPU.Build.0 = Release|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.ActiveCfg = Release|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Release|x64.Build.0 = Release|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.ActiveCfg = Debug|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Debug|x64.Build.0 = Debug|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|Any CPU.Build.0 = Release|Any CPU
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.ActiveCfg = Release|x64
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -201,6 +223,8 @@ Global
{E4FD522A-F44B-4401-9A18-542D42136E6B} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C} = {6EFB114A-05DF-49C9-B0EF-26BDF7EFE881}
{C3A7E5D1-92B4-4F68-8A1E-5B3D7F9C2E40} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
{4B9E2F7A-6C1D-4E8B-A3F5-7D0C9B2E6A14} = {8D2C6B4E-1F3A-4C5B-9E7D-2A6F8B0C4D1E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E2F3A4B5-C6D7-4E8F-9A0B-1C2D3E4F5A6B}
+96 -8
View File
@@ -32,7 +32,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| Projet | Rôle |
|---|---|
| `src/GameLauncher` | Application WPF (fenêtres, thème, ViewModels, registre, raccourci) |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`) |
| `src/GameLauncher.Core` | Logique pure, sans WPF : manifest, téléchargement, installation, mise à jour, vérification, désinstallation, catalogue des jeux (`Catalog/`), mode maintenance (`Maintenance/`) |
| `tools/ManifestBuilder` | Outil console de publication : génère `manifest.json`, l'archive ZIP et le dossier `objects/` |
| `tools/LauncherPublisher` | Outil console de publication **du launcher** : clés de signature, `launcher-update.json` signé (voir §7) |
| `src/GameLauncher.Contracts` | Protocole partagé launcher ⇄ Launcher API : routes, échanges, groupes et permissions, messages signés, catalogue, notes de version (modèle et limites), contrôle des manifests publiés (voir §8) |
@@ -41,6 +41,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `src/GameLauncher.Modules.Admin` | Module « Administration » : clés, connexion, invitation, verrouillage, tableau de bord ; modules « Catalogue des jeux » (`Catalog/`, page « Jeux »), « Notes de version » (`Changelog/`) et « Publication » (`Publishing/`) (vues dans `src\GameLauncher\Views\Admin` et `Themes\Admin.xaml`) |
| `src/GameLauncher.Modules.Feedback` | Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) et envoi anonyme ; fenêtre dans `src\GameLauncher\Views\FeedbackWindow.xaml` |
| `server/GameLauncher.Server` | **Launcher API** (ASP.NET Core) : serveur d'administration, déployé sur le VPS (voir §8) |
| `website/GameLauncher.Website` | **Site vitrine** (ASP.NET Core Razor Pages) : présentation des jeux et du launcher, téléchargement, notes de version, actualités ; isolé de la Launcher API (voir §9) |
| `tools/AdminKeyTool` | Clé d'administrateur et gestion des administrateurs en ligne de commande (`tools\admin-key.bat`) |
| `tools/UiSnapshot` | Captures de **toutes les fenêtres** (états de la fenêtre principale, dialogues, menu d'administration et ses pages) avec des données de démonstration, et rapport des problèmes de mise en page : `tools\ui-snapshot.bat` (voir `tools\UiSnapshot\LISEZMOI.md`) |
| `tests/GameLauncher.Tests` | Tests automatisés (xUnit) : Test › Explorateur de tests › Exécuter tout |
@@ -52,7 +53,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)
@@ -366,7 +368,7 @@ fait au démarrage suivant. Les entrées *Applications installées* (`Uninstall\
| Vignette d'un jeu (liste des jeux) | `launcher.json` → `"icon"` (image carrée) ; sinon fond du thème + initiales |
| Icône | Remplacez `Assets/launcher.ico` |
| Icônes de l'interface | `Themes/Icons.xaml` (tracés 24×24, compatibles [Material Design Icons](https://pictogrammers.com/library/mdi/)) |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Vérifier un réglage graphique | `tools\ui-snapshot.bat` (ex. `--filter "main-*" --scale 2`) : un PNG par écran (ou WebP avec `--format webp`) dans `artifacts\ui-snapshots`, planche `index.html`, problèmes détectés dans `report.md` |
| Textes | `launcher.json` (nom, accroche), `ViewModels/GameViewModel.cs` ; sans recompiler : menu d'administration › **Jeux** (§8) |
| Fréquence des recherches de mises à jour | `launcher.json` → `updateCheckIntervalMinutes` |
| Dossier des données du jeu Unity (« Société\Produit ») | `launcher.json` → `gameDataFolder` |
@@ -428,7 +430,7 @@ Le launcher (un seul `GameLauncher.exe`) vérifie au démarrage s'il existe une
lui-même, la télécharge, l'installe et **redémarre tout seul**. Réglable dans **Paramètres du launcher** (roue crantée) :
interrupteur « Mettre à jour le launcher automatiquement » (commun à tous les jeux) et bouton
**« Vérifier les mises à jour »** (puis « Installer la x.y.z »). Option désactivée : un bandeau signale
simplement qu'une nouvelle version existe.
simplement qu'une nouvelle version existe — sauf pour une **version prioritaire** (ci-dessous), installée quand même.
### Sécurité
@@ -440,6 +442,7 @@ simplement qu'une nouvelle version existe.
| Pas de rétrogradation | Seule une version **strictement supérieure** est installée ; le produit (`launcherId`) doit correspondre. |
| Tailles bornées | `launcher-update.json` ≤ 256 Ko ; un serveur qui envoie plus que la taille annoncée est coupé (vaut aussi pour les fichiers du jeu). |
| Retour arrière automatique | Si la nouvelle version ne démarre pas dans les 60 s (ou n'est pas la version annoncée), elle est arrêtée et l'ancienne remise en place. Cette version n'est plus proposée automatiquement (bouton manuel uniquement). |
| Version prioritaire signée | L'indication « prioritaire » fait partie du contenu signé : un serveur compromis ne peut ni imposer une mise à jour, ni retirer la priorité d'un correctif. Elle ne permet pas d'installer autre chose qu'une version signée, strictement supérieure. |
### Déroulement
@@ -462,6 +465,22 @@ La mise à jour est refusée tant qu'un jeu est lancé ou qu'un téléchargement
est dans un dossier protégé (Program Files), il ne peut pas se remplacer : le message l'explique.
Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la version publiée se remplace.
### Version prioritaire (correctif critique)
Une version publiée avec **`--priority`** est installée au démarrage **même chez les joueurs qui ont
désactivé** « Mettre à jour le launcher automatiquement ». Exemple : une faille critique est corrigée en 3.4.0.
| Point | Comportement |
|---|---|
| Réglage du joueur | **Jamais modifié** : `autoUpdate` reste à `false` dans `self-update.json`. Effet ponctuel : une fois le launcher à jour, les versions normales suivantes ne sont plus installées automatiquement chez lui. |
| Versions suivantes | La priorité est **reprise** par les publications suivantes (`priorityVersion` dans `launcher-update.json`) : si la 3.5.0 (normale) sort après le correctif 3.4.0, un launcher encore en 3.3.x passe directement en 3.5.0 ; un launcher déjà en 3.4.0 n'est pas concerné. `--clear-priority` arrête la reprise (priorité publiée par erreur). |
| Fenêtre de mise à jour | Pastille **« Prioritaire »** et texte « Mise à jour importante… votre réglage ne change pas ». « Plus tard » reste possible : nouvelle tentative au démarrage suivant, bandeau d'avertissement en attendant. |
| Erreur de téléchargement | Retentée au démarrage suivant (une version normale n'est plus retentée automatiquement). |
| La nouvelle version ne démarre pas | Retour arrière, puis plus de tentative automatique (sinon 60 s d'attente à chaque lancement) : bandeau d'avertissement et statut orange dans les Paramètres du launcher, installation par le bouton. |
| Dossier protégé (Program Files) | Pas d'installation possible sans droits administrateur : bandeau d'avertissement. |
| Option désactivée | Le launcher interroge désormais le serveur au démarrage (`launcher-update.json`, 1 à 2 Ko, 6 s au plus) pour savoir si une version prioritaire existe. |
| Launchers antérieurs à 3.4.0 | Ignorent l'indication : ils ne suivent que leur propre réglage. La priorité ne vaut que pour les joueurs déjà en 3.4.0 ou plus. |
### Mise en place (une seule fois)
1. **Clés** : double-cliquez `tools\keygen-launcher.bat`. Choisissez une phrase de passe (12 caractères
@@ -489,6 +508,9 @@ Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la ver
signé par votre clé. Les launchers antérieurs à 1.1.3 reçoivent toujours le titre et la liste « à plat ».
3. Double-cliquez `tools\publish-launcher-update.bat` : compilation, signature (phrase de passe demandée),
puis envoi WinSCP — l'exe d'abord, `launcher-update.json` **en dernier**.
**Correctif critique** : depuis une invite de commandes, `tools\publish-launcher-update.bat --priority`
(confirmation demandée avant la signature). `--clear-priority` ne reprend pas la priorité déjà publiée.
Le résumé affiché indique la priorité (« normale », « PRIORITAIRE » ou « version prioritaire x.y.z reprise »).
Vérification à tout moment :
`dotnet run --project tools\LauncherPublisher -- verify --manifest https://…/launcher/launcher-update.json --public-key src\GameLauncher\Security\update-signing.pub`
@@ -512,7 +534,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,16 +542,39 @@ 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
l'envoi avec les règles du serveur ; informations techniques facultatives (versions, Windows, langue) montrées avant
l'envoi, cochées par défaut pour un bug. Demande **anonyme** : aucun compte, aucune adresse IP enregistrée ; le
joueur reçoit une référence `FB-XXXX-XXXX`. En cas d'échec (réseau, serveur), la saisie est conservée. Bouton absent
joueur reçoit une référence `FB-XXXX-XXXX`. **Captures d'écran** : 3 au plus (fichier ou Ctrl+V), image reconnue à
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. Chaque consultation est inscrite au journal d'audit.
En cours, Traitée, Rejetée, Doublon), note interne, suppression, captures d'écran (miniature, agrandissement). Une
demande contenant un **lien** (http, https, www, domaine, IP, e-mail, formes déguisées…) est signalée par un
avertissement, liens désamorcés et jamais cliquables. Statut, note et suppression sont des requêtes **signées**.
Chaque consultation est inscrite au journal d'audit.
Purge automatique : 12 mois après la clôture, 24 mois au plus ; le journal d'audit est purgé au-delà de 12 mois.
* **Ouvrir le menu** : `Ctrl+Maj+Alt+A` dans la fenêtre du launcher, ou **5 clics rapides** (en
moins de 3 s) sur le logo en haut à gauche. Accès discret, pas secret : seule la connexion par clé,
@@ -616,7 +661,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
@@ -661,3 +708,44 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
| Créer sa clé, tester la connexion, inviter/approuver | Menu d'administration du launcher (pages « Mes appareils », « Administrateurs », « Groupes et permissions ») ; en ligne de commande : `tools\admin-key.bat` (`help` pour la liste) |
| Signer le trousseau des clés autorisées à signer le contenu | Page « Signature » › « Exporter la demande… », puis glisser le fichier sur `tools\sign-keyring.bat` (clé racine, hors ligne ; nouvelles clés signalées, à vérifier de vive voix), puis « Publier le trousseau signé… » |
| Lancer le serveur en local (F5) | Projet `GameLauncher.Server`, profil *Development* : `http://127.0.0.1:5080/api/` |
## 9. Site vitrine
Site public du studio : `website/GameLauncher.Website` (ASP.NET Core Razor Pages, .NET 8), à déployer de
préférence sur un **autre VPS** que celui du launcher. Installation, nginx, HTTPS, mentions légales :
**`website/deploy/LISEZMOI.md`**. Publication : `tools\publish-site.bat`. En local : projet de démarrage
*GameLauncher.Website*, F5 → `http://127.0.0.1:5090/`.
| Page | Contenu |
|---|---|
| `/` | Accueil : téléchargement du launcher, jeux, dernières actualités |
| `/launcher` | Présentation du launcher (fonctions joueur), téléchargement, première installation, notes de la **dernière** version, lien vers les publications Gitea, questions fréquentes |
| `/jeux`, `/jeux/exovival-zombie-reborn` | Liste des jeux ; page d'Exovival (histoire, contenu, cassettes, extension « L'abri » en développement, configuration) |
| `/jeux/<jeu>/notes-de-version` | **Toutes** les notes de version du jeu, lues dans son `manifest.json` |
| `/jeux/projet-multijoueur` | Jeu en préparation (page générique, `Site:Games` d'`appsettings.json`) |
| `/actualites` | Articles en Markdown : `website/GameLauncher.Website/Content/News/*.md` |
| `/mentions-legales`, `/confidentialite` | Éditeur, hébergeur ; données (aucun cookie, journaux 14 jours) |
* **Thème par univers** : `launcher` (violet / cyan, comme le thème « default »), `exovival` (noir, vert, titres
condensés ; en-tête « lampe UV » qui révèle un message à l'encre invisible) et `multi` (jeu en préparation).
* **Isolé de la Launcher API** : le site ne l'appelle jamais. Seuls `launcher-update.json` et les `manifest.json`
sont lus, **côté serveur**, en arrière-plan (toutes les 10 min, requêtes conditionnelles, taille et délai bornés,
https uniquement, aucune redirection suivie). Les pages ne déclenchent aucune requête sortante.
* **Téléchargement du launcher** : affiché seulement si la signature de `launcher-update.json` est valide (même
clé publique et même code que le launcher, clé intégrée à l'exécutable du site) et si l'exe est sur le même
serveur. Version, taille et empreinte SHA-256 affichées. Fichier refusé = bouton retiré ; serveur injoignable =
dernière version valide conservée. Les sections « Serveur », « Développement »… des notes du launcher ne sont
pas affichées (`Site:Launcher:HiddenSections`).
* **Notes de version** : mêmes bornes et même nettoyage que dans le launcher (`ChangelogLimits.Normalize`), textes
toujours encodés (jamais interprétés comme du HTML).
* **Sécurité HTTP** : politique de sécurité du contenu stricte (aucun script en ligne, aucune ressource externe,
polices servies par le site), HSTS, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy` ;
lecture seule (`GET`/`HEAD`, le reste en 405) ; limite de requêtes par IP (site + nginx) ; aucun cookie ;
aucune donnée écrite sur le disque.
* **Service** (`website/deploy/lanisys-site.service`) : compte temporaire systemd, système en lecture seule,
connexions sortantes limitées au serveur du launcher (liste écrite par `installer-site.sh`).
* **Liseuse Markdown** des actualités (`Services/SafeMarkdown.cs`, sans dépendance) : HTML brut affiché tel quel,
liens `javascript:`/`data:` retirés, images du site uniquement.
* Tests : `tests/GameLauncher.Tests/Website/WebsiteTests.cs` (signature, adresses, bornes, Markdown, actualités).
* Captures du launcher (`wwwroot/img/launcher/`) : reprises de `docs/preview*.png` ; à remplacer par des captures
à jour, directement en WebP : `tools\ui-snapshot.bat --filter "main-*" --format webp` (voir `tools\UiSnapshot\LISEZMOI.md`).
+213 -11
View File
@@ -68,7 +68,7 @@ Conséquences :
| `src/GameLauncher.Modules` | net8.0 | **Nouveau (phase 2).** Socle des modules, sans WPF : `ILauncherModule`, registre, pages d'administration, boîtes de dialogue abstraites, MVVM commun |
| `src/GameLauncher.Modules.Admin` | net8.0 | **Nouveau (phase 2).** Module « Administration » : services (clés, connexion, invitation, verrouillage) et ViewModels, testés sans interface |
| `src/GameLauncher.Modules.Feedback` | net8.0 | **Nouveau (7d).** Module « Aide » côté joueur : formulaire de demande (avis, évolution, bug, autre) généré à partir des formulaires partagés, envoi anonyme (§ 11 quinquies) |
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) |
| `src/GameLauncher.Core` | net8.0 | Logique du jeu : manifest, téléchargement, installation… ; **phase 3** : lecture, cache et application du catalogue (`Catalog/`) ; **7f** : lecture et vérification du mode maintenance (`Maintenance/`) |
| `src/GameLauncher.Contracts` | net8.0 | **Nouveau.** Protocole partagé launcher ⇄ serveur : routes, DTO, rôles et permissions, messages signés, contrôle des clés publiques ; catalogue (phase 3) ; modèle et bornes des notes de version (phase 4) |
| `src/GameLauncher.ApiClient` | net8.0 | **Nouveau.** Client HTTP de l'API (connexion par défi signé, requêtes signées), fichier de clé chiffré |
| `server/GameLauncher.Server` | net8.0 (ASP.NET Core) | **Nouveau.** Launcher API : modules serveur, authentification, administrateurs, audit, outil en ligne de commande |
@@ -324,6 +324,8 @@ premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les
| `files.manage` | ✔ | ✔ | | Fichiers du serveur : consultation du site, suppression des fichiers inutilisés, nettoyage d'« objects » |
| `feedback.read` | ✔ | ✔ | | Demandes des joueurs : lecture (consultations inscrites au journal) — § 11 quinquies |
| `feedback.manage` | ✔ | ✔ | | Demandes des joueurs : statut, note interne, suppression |
| `feedback.forms` | ✔ | ✔ | | Formulaires des demandes : modification et publication signée — § 11 sexies |
| `maintenance.manage` | ✔ | ✔ | | Mode maintenance du launcher : activation, désactivation, message (publication signée) — § 11 septies |
Une permission intégrée ajoutée après l'installation (ex. `feedback.*`) est donnée **automatiquement** au groupe
Propriétaire seulement : les groupes existants ne changent pas d'eux-mêmes (aucune élévation de droits sans l'action
@@ -697,10 +699,12 @@ d'inversion du sens d'écriture, d'usage privé et demi-caractères refusés ; f
| Route | Accès | Signée | Rôle |
|---|---|:-:|---|
| `POST v1/feedback/challenge` | anonyme | | Défi anti-spam sans état (HMAC, secret en mémoire) |
| `POST v1/feedback` | anonyme | | Dépôt : `{ reference }` seulement |
| `POST v1/feedback` | anonyme | | Dépôt : `{ reference }` seulement (+ jeton d'envoi si des captures sont annoncées) |
| `PUT v1/feedback/attachments/{n}` | anonyme + jeton | | Capture d'écran n° n (1 à 3) de la demande que le jeton désigne |
| `GET v1/feedback/overview` | `feedback.read` | | Compteurs par jeu, réception activée, plafond atteint, durées de conservation |
| `GET v1/games/{id}/feedback?kind=&status=&cursor=&limit=` | `feedback.read` | | Liste (100 par page, curseur stable, filtres `open`/`closed`/statut) |
| `GET v1/games/{id}/feedback/{id}` | `feedback.read` | | Détail |
| `GET v1/games/{id}/feedback/{id}/attachments/{n}` | `feedback.read` | | Capture d'écran (octets bruts, `nosniff`) |
| `PUT v1/games/{id}/feedback/{id}` | `feedback.manage` | ✔ | Statut et note interne (révision attendue, 409 sinon) |
| `DELETE v1/games/{id}/feedback/{id}` | `feedback.manage` | ✔ | Suppression définitive |
@@ -709,29 +713,56 @@ limite de débit ASP.NET (`feedback`, 10/min par IP) et nginx (`feedback_par_ip`
travail** (`FeedbackProof` : SHA-256 avec 18 bits à zéro par défaut, défi à usage unique valable 10 min) ; jeu parmi
ceux configurés (`Feedback:Games`, sinon `Games` ; l'identifiant **configuré** sert seul à construire un chemin, `.`
et `..` refusés) ; contenu ; 3 demandes / 10 min et 10 / jour par IP (IPv6 : /64), **comptées en mémoire seulement**
(429 + `Retry-After`) ; plafonds globaux (300 / jour / jeu, 5 000 / jeu, 100 Mo, 500 Mo libres : 503
`feedback_full`). Le serveur pose lui-même l'identifiant (128 bits), la référence (`FB-XXXX-XXXX`, 40 bits), la date
(429 + `Retry-After`) ; plafonds globaux (300 / jour / jeu, 5 000 / jeu, 1 Go captures comprises, 500 Mo libres :
503 `feedback_full`). Le serveur pose lui-même l'identifiant (128 bits), la référence (`FB-XXXX-XXXX`, 40 bits), la date
et le statut.
**Captures d'écran** (3 au plus par demande, 4 Mo chacune, **PNG ou JPEG uniquement**) : le joueur choisit une image
(PNG, JPEG, BMP, GIF, WebP, reconnue à son **contenu**, jamais à l'extension ; 30 Mo au plus, dimensions lues avant
tout décodage) ou la colle (Ctrl+V) ; Windows la décode (preuve que c'est une image), elle est réduite (3 840 px,
surface de la limite serveur) et **réencodée sans métadonnées** (ni GPS, ni appareil, ni date) ; le nom du fichier
n'est jamais envoyé. Envoi en deux temps : la demande annonce `AttachmentCount`, le serveur répond avec un **jeton
d'envoi** `<id>.<secret>` (128 bits, seule son empreinte SHA-256 est gardée, en mémoire, 15 min, comparaison à temps
constant) valable pour CETTE demande et les numéros annoncés ; chaque capture part en `PUT` (corps 4 Mo + 16 Ko,
en-tête `X-GL-Feedback-Upload`). Le serveur contrôle **réellement** l'image sans bibliothèque de décodage
(`FeedbackImages.Sanitize`) : PNG (signature, IHDR, CRC de chaque bloc, données zlib décompressées et taille exacte
des lignes vérifiée, filtres valides, IEND final sans données après) ou JPEG (marqueurs, SOF0/1/2, tables, SOS, EOI,
rien après) ; dimensions 16 à 4 096 px, 9,4 Mpx au plus ; puis **retire toutes les métadonnées** (blocs PNG
facultatifs : textes, EXIF, profil… ; JPEG : APP1 à APP15 dont EXIF/XMP/GPS, commentaires). Une capture refusée
n'annule pas la demande (le joueur est prévenu). Audit `feedback.attachment` sans IP. Dans le menu, chaque capture est
**décodée à nouveau** par le launcher (contrôle + dimensions) avant affichage ; aucun fichier n'est écrit sur le PC.
**Liens** (`FeedbackLinks`) : toute demande contenant un lien est signalée (icône dans la liste, bandeau
d'avertissement et champs concernés surlignés dans la fiche ; liens affichés **désamorcés**, ex. `hxxps[://]site[.]com`,
jamais cliquables). Formes reconnues : `http(s)://`, `ftp://`, `file:`, schémas dangereux (`javascript:`, `data:`,
`vbscript:`, `ms-*:`, `steam:`…), `www.`, domaines nus avec extension connue (`site.ru`, `exemple.com/chemin`), adresses
IP, chemins réseau `\\serveur\partage`, adresses e-mail, formes déguisées (`hxxp`, `[.]`, `(dot)`, ` dot `,
caractères pleine chasse).
**Anonymat** : ni adresse IP ni identifiant du PC dans la demande ; audit `feedback.submit` **sans IP** (référence,
jeu, type) ; refus résumés par `WriteAnonymousFailure` sans IP ; nginx `access_log off` sur les deux routes ;
informations techniques facultatives (versions du launcher et du jeu, Windows, langue), cochées par défaut pour un
bug seulement, contenu exact montré au joueur avant l'envoi. Agent utilisateur générique (`GameLauncher/<version>`).
Le launcher ne contacte l'API qu'à l'envoi.
**Stockage** (`Storage/FeedbackStore`) : un fichier JSON par demande, `feedback/<jeu>/<AAAA-MM>/<id>.json` dans les
données privées (0700/0600), écriture atomique, index en mémoire construit au premier accès (fichier illisible ignoré
**Stockage** (`Storage/FeedbackStore`) : un fichier JSON par demande, `feedback/<jeu>/<AAAA-MM>/<id>.json` (captures :
`<id>-<n>.png|jpg` à côté, supprimées avec la demande) dans les données privées (0700/0600), écriture atomique, index en mémoire construit au premier accès (fichier illisible ignoré
et signalé), verrou en mémoire (seul le service écrit ici). **SQLite évalué et écarté pour ce module** : volumes bornés,
aucune relation, une demande = un document modifié isolément, purge = suppression de fichiers ; SQLite ajouterait une
dépendance native (paquet NuGet + bibliothèque `e_sqlite3`) à maintenir sur le VPS et une sauvegarde spécifique, sans
gain de sécurité. À réévaluer avec le module « comptes + échanges » (relations comptes ⇄ demandes ⇄ messages,
requêtes croisées, volumes non bornés), où SQLite serait légitime (requêtes paramétrées uniquement, fichier 0600 dans
requêtes croisées, volumes non bornés), où SQLite serait légitime. Captures : fichiers également (des blobs binaires
en base alourdiraient sauvegardes et purge sans rien apporter) (requêtes paramétrées uniquement, fichier 0600 dans
les données privées, mode WAL, sauvegarde par l'API de sauvegarde).
**Consultations et traitement** : `feedback.view` inscrit à chaque ouverture de liste (première page) et à chaque
lecture d'une demande (une fois par demande et par clé toutes les 10 min), sans le contenu ; `feedback.update`
(ancien → nouveau statut, note modifiée oui/non), `feedback.delete` (référence). Statuts : Nouvelle, En cours, Traitée,
Rejetée, Doublon. Note interne 500 caractères, jamais visible du joueur.
Rejetée, Doublon. Note interne 500 caractères, jamais visible du joueur. Comme partout dans le menu, les **écritures**
(`PUT`, `DELETE`) sont **signées** par la clé de l'administrateur (horodatage + nonce, rejeu refusé) ; les lectures
(liste, détail, captures) exigent la session et `feedback.read`, revérifiés à chaque requête, et sont journalisées.
Le journal d'audit du menu affiche des libellés lisibles (« Demande de joueur reçue », « … consultée », « … traitée »,
« Capture d'écran reçue »…) et une catégorie « Demandes des joueurs ».
**Conservation** (`Hosting/RetentionService`, au démarrage puis toutes les 24 h) : demande close supprimée
`RetentionClosedMonths` (12) après sa clôture, toute demande au plus `RetentionMaxMonths` (24) après réception
@@ -741,7 +772,9 @@ Rejetée, Doublon. Note interne 500 caractères, jamais visible du joueur.
**Launcher (joueur)** : bouton **Aide** (bulle avec point d'interrogation) dans la barre de titre, absent sans
`apiUrl` (même règle que le menu : `launcher.json` intégré en Release). Fenêtre modale `FeedbackWindow` (le jeu est
figé à l'ouverture) : type (4 tuiles) › formulaire (contrôle local, compteurs de caractères, étoiles, saisie conservée
en cas d'échec, Échap sans effet pendant la saisie) › référence (copie). ViewModel `FeedbackFormViewModel`
en cas d'échec, Échap sans effet pendant la saisie) › référence (copie). Formulaire limité à une hauteur de base
(500 px, moins sur un petit écran) avec ascenseur ; la fenêtre reste centrée quand l'étape change ; molette gérée
par `WheelAssist` comme dans le menu. ViewModel `FeedbackFormViewModel`
(`GameLauncher.Modules.Feedback`), envoi par `LauncherApiClient.SendFeedbackAsync` (défi résolu hors du fil de
l'interface).
@@ -751,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 :
@@ -779,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
@@ -822,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
@@ -854,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()
};
}
@@ -157,7 +157,7 @@ public sealed class LauncherApiOptions
/// <summary>Plafonds : demandes reçues par jeu sur 24 heures, demandes conservées par jeu, taille totale (Mo).</summary>
public int MaxPerGamePerDay { get; set; } = 300;
public int MaxPerGame { get; set; } = 5000;
public int MaxTotalMegabytes { get; set; } = 100;
public int MaxTotalMegabytes { get; set; } = 1024; // captures d'écran comprises (4 Mo chacune au plus)
/// <summary>Espace disque libre minimal (Mo) : en dessous, les nouvelles demandes sont refusées.</summary>
public int MinFreeDiskMegabytes { get; set; } = 500;
@@ -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>();
}
@@ -37,10 +43,16 @@ public sealed class FeedbackModule : IApiModule
api.MapPost(ApiRoutes.FeedbackChallenge, Challenge).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback);
api.MapPost(ApiRoutes.Feedback, Submit).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback)
.WithBodyLimit(FeedbackRules.MaxRequestBytes);
api.MapPut(ApiRoutes.FeedbackAttachmentPattern, UploadAttachment).AllowAnonymous().RequireRateLimiting(RateLimitPolicies.Feedback)
.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);
api.MapPut(ApiRoutes.GameFeedbackItemPattern, Update).RequirePermission(AdminPermissions.FeedbackManage).RequireSignedRequest();
api.MapDelete(ApiRoutes.GameFeedbackItemPattern, Delete).RequirePermission(AdminPermissions.FeedbackManage).RequireSignedRequest();
}
@@ -53,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();
@@ -82,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))");
@@ -99,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) ?? "?"})");
@@ -109,8 +135,96 @@ 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"));
return Results.Json(new FeedbackSubmitted { Reference = record.Reference }, ApiJson.Options);
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
{
Reference = record.Reference,
UploadToken = record.AttachmentCount > 0 ? guard.CreateUploadToken(record.Id, record.AttachmentCount) : null
}, ApiJson.Options);
}
/// <summary>
/// Capture d'écran d'une demande qui vient d'être envoyée (jeton d'envoi remis à ce seul launcher). L'image est
/// contrôlée (vraie image PNG ou JPEG, dimensions, structure) puis nettoyée de ses métadonnées avant d'être
/// enregistrée ; sans adresse IP dans l'audit, comme la demande.
/// </summary>
private static IResult UploadAttachment(int index, HttpContext context, FeedbackStore store, FeedbackGuard guard, AuditLog audit,
TimeProvider time)
{
if (!store.Enabled) return NotConfigured();
var id = guard.CheckUpload(context.Request.Headers[FeedbackUpload.TokenHeader].ToString(), index);
if (id is null || store.FindById(id) is null)
{
audit.WriteAnonymousFailure("feedback.attachment", null, null, "jeton d'envoi absent, expiré ou invalide");
return ApiResults.Error(403, ApiErrorCodes.Forbidden, "Envoi de la capture refusé (délai dépassé ?).");
}
var error = FeedbackImages.Sanitize(context.CapturedBody(), out var info, out var clean);
if (error is not null)
{
audit.WriteAnonymousFailure("feedback.attachment", null, null, $"capture refusée ({error})");
return ApiResults.BadRequest(error);
}
var record = store.AddAttachment(id, index, clean!, info!, time.GetUtcNow());
if (record is null)
return ApiResults.Error(503, ApiErrorCodes.FeedbackFull, "Le service des demandes est momentanément saturé : capture non enregistrée.");
audit.Write("feedback.attachment", AuditResults.Ok, target: record.Reference,
details: $"jeu {record.GameId} ; capture {index} : {info!.Format.ToUpperInvariant()} {info.Width} × {info.Height}, {clean!.Length / 1024} Ko (métadonnées retirées)");
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
@@ -134,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.");
@@ -156,10 +270,25 @@ 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);
}
private static IResult GetAttachment(string gameId, string id, int index, HttpContext context, FeedbackStore store, AuditLog audit,
FeedbackConsultations consultations)
{
var game = store.ResolveGame(gameId);
if (game is null || store.Find(game, id) is not { } record || store.ReadAttachment(record, index) is not { } bytes)
return ApiResults.NotFound("Capture introuvable.");
var actor = context.Admin();
if (consultations.ShouldRecord(actor.Key.KeyId, $"attachment:{record.Id}:{index}", continuation: true))
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
record.Reference, $"jeu {game} ; capture {index}");
// Type générique : le contenu n'est jamais interprété comme une page par un navigateur.
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
return Results.Bytes(bytes, FeedbackUpload.ContentType);
}
private static IResult Update(string gameId, string id, FeedbackUpdateRequest request, HttpContext context, FeedbackStore store,
AuditLog audit, TimeProvider time)
{
@@ -189,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);
}
@@ -122,6 +122,52 @@ public sealed class FeedbackGuard
}
}
// ------------------------------------------------------------------ Jetons d'envoi des captures
private sealed record PendingUpload(byte[] SecretHash, DateTimeOffset Expires, int Count);
private readonly ConcurrentDictionary<string, PendingUpload> _uploads = new(StringComparer.Ordinal);
/// <summary>
/// Jeton d'envoi des captures d'une demande qui vient d'être enregistrée : « &lt;id&gt;.&lt;secret 256 bits&gt; ». Seule
/// l'empreinte du secret est gardée, en mémoire (un redémarrage du service annule les envois en attente).
/// </summary>
public string CreateUploadToken(string id, int count)
{
var now = _time.GetUtcNow();
if (_uploads.Count >= MaxTracked)
{
foreach (var (key, pending) in _uploads)
if (pending.Expires <= now) _uploads.TryRemove(key, out _);
if (_uploads.Count >= MaxTracked) throw new StoreBusyException("Trop d'envois de captures en attente.");
}
var secret = RandomNumberGenerator.GetBytes(32);
_uploads[id] = new PendingUpload(SHA256.HashData(secret), now + TimeSpan.FromMinutes(FeedbackUpload.TokenLifetimeMinutes), count);
return id + "." + Base64Url.Encode(secret);
}
/// <summary>
/// Identifiant de la demande si le jeton est valable pour cette capture (numéro dans le nombre annoncé, délai non
/// dépassé), sinon null. Comparaison en temps constant.
/// </summary>
public string? CheckUpload(string? token, int index)
{
if (token is not { Length: > 33 and < 120 }) return null;
var dot = token.IndexOf('.');
if (dot != 32) return null;
var id = token[..dot];
if (!FeedbackRules.IsValidId(id) || !_uploads.TryGetValue(id, out var pending)) return null;
if (pending.Expires <= _time.GetUtcNow())
{
_uploads.TryRemove(id, out _);
return null;
}
if (!Base64Url.TryDecode(token[(dot + 1)..], out var secret) || secret.Length != 32 ||
!CryptographicOperations.FixedTimeEquals(SHA256.HashData(secret), pending.SecretHash))
return null;
return index >= 1 && index <= pending.Count ? id : null;
}
private byte[] Mac(ReadOnlySpan<byte> payload)
{
var data = new byte[Domain.Length + payload.Length];
@@ -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,10 +21,23 @@ 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();
public FeedbackTechInfo? Tech { get; init; }
/// <summary>Captures annoncées à l'envoi (celles réellement reçues : <see cref="Attachments"/>).</summary>
public int AttachmentCount { get; init; }
/// <summary>Captures reçues, nettoyées (fichiers <c>&lt;id&gt;-&lt;n&gt;.png|jpg</c> à côté de la demande).</summary>
public List<FeedbackAttachmentInfo> Attachments { get; init; } = new();
public string Status { get; init; } = FeedbackStatuses.New;
public long Revision { get; init; } = 1;
public string? AdminNote { get; init; }
@@ -41,14 +54,10 @@ 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,
Revision = Revision
};
@@ -56,8 +65,11 @@ public sealed record FeedbackRecord
{
Summary = ToSummary(),
FormVersion = FormVersion,
FormRevision = FormRevision,
Form = Form,
Fields = Fields,
Tech = Tech,
Attachments = Attachments.OrderBy(a => a.Index).ToList(),
AdminNote = AdminNote,
StatusChangedBy = StatusChangedBy
};
@@ -115,7 +127,7 @@ public sealed class FeedbackStore
// ------------------------------------------------------------------ Envoi
/// <summary>Raison d'un refus d'enregistrement pour cause de plafond (null : place disponible).</summary>
public string? FullReason(string gameId, DateTimeOffset now)
public string? FullReason(string gameId, DateTimeOffset now, long extraBytes = 0)
{
lock (_lock)
{
@@ -127,7 +139,7 @@ public sealed class FeedbackStore
var lastDay = game.Values.Count(r => r.ReceivedAt > now - TimeSpan.FromDays(1));
if (lastDay >= _settings.MaxPerGamePerDay) return $"{lastDay} demandes reçues en 24 h pour ce jeu (plafond {_settings.MaxPerGamePerDay})";
}
if (_totalBytes >= (long)_settings.MaxTotalMegabytes * 1024 * 1024)
if (_totalBytes + extraBytes >= (long)_settings.MaxTotalMegabytes * 1024 * 1024)
return $"taille totale des demandes {_totalBytes / (1024 * 1024)} Mo (plafond {_settings.MaxTotalMegabytes} Mo)";
if (FreeDiskBytes() is { } free && free < (long)_settings.MinFreeDiskMegabytes * 1024 * 1024)
return $"espace disque libre {free / (1024 * 1024)} Mo (minimum {_settings.MinFreeDiskMegabytes} Mo)";
@@ -136,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)
{
@@ -155,10 +168,13 @@ 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),
Tech = submission.Tech,
AttachmentCount = submission.AttachmentCount,
Status = FeedbackStatuses.New,
Revision = 1
};
@@ -168,6 +184,57 @@ public sealed class FeedbackStore
}
}
// ------------------------------------------------------------------ Captures d'écran
/// <summary>Demande (tous jeux confondus) d'après son identifiant interne.</summary>
public FeedbackRecord? FindById(string id)
{
if (!FeedbackRules.IsValidId(id)) return null;
lock (_lock)
foreach (var game in Index().Values)
if (game.TryGetValue(id, out var record)) return record;
return null;
}
/// <summary>
/// Enregistre une capture DÉJÀ contrôlée et nettoyée (<see cref="FeedbackImages.Sanitize"/>). Null : plafond atteint.
/// Une capture de même numéro est remplacée (nouvel essai après une coupure).
/// </summary>
public FeedbackRecord? AddAttachment(string id, int index, byte[] clean, FeedbackImages.ImageInfo info, DateTimeOffset now)
{
lock (_lock)
{
var record = FindById(id) ?? throw ApiRejection.NotFound("Demande introuvable.");
if (index < 1 || index > record.AttachmentCount) throw ApiRejection.BadRequest("Numéro de capture invalide.");
var previous = record.Attachments.FirstOrDefault(a => a.Index == index);
if (FullReason(record.GameId, now, clean.Length - (previous?.Bytes ?? 0)) is not null) return null;
var path = AttachmentPath(record, index, info.Format);
if (previous is not null && previous.Format != info.Format) DeleteAttachmentFile(record, previous);
var oldSize = File.Exists(path) ? new FileInfo(path).Length : 0;
SecureFiles.WriteAllBytesAtomic(path, clean);
_totalBytes += clean.Length - oldSize;
var attachments = record.Attachments.Where(a => a.Index != index)
.Append(new FeedbackAttachmentInfo { Index = index, Format = info.Format, Width = info.Width, Height = info.Height, Bytes = clean.Length })
.OrderBy(a => a.Index).ToList();
var updated = record with { Attachments = attachments };
Write(updated);
Index()[record.GameId][id] = updated;
return updated;
}
}
/// <summary>Contenu d'une capture (null : absente).</summary>
public byte[]? ReadAttachment(FeedbackRecord record, int index)
{
var info = record.Attachments.FirstOrDefault(a => a.Index == index);
if (info is null) return null;
var path = AttachmentPath(record, index, info.Format);
if (!File.Exists(path) || IsLink(path) || new FileInfo(path).Length > FeedbackImages.MaxBytes) return null;
return File.ReadAllBytes(path);
}
// ------------------------------------------------------------------ Consultation
public IReadOnlyList<FeedbackGameCounts> Counts()
@@ -342,8 +409,25 @@ public sealed class FeedbackStore
_totalBytes += new FileInfo(path).Length - oldSize;
}
private string AttachmentPath(FeedbackRecord record, int index, string format)
{
if (format is not (FeedbackImages.Png or FeedbackImages.Jpeg) || index is < 1 or > FeedbackImages.MaxAttachments)
throw new InvalidOperationException("Capture invalide.");
return Path.Combine(Path.GetDirectoryName(PathOf(record))!, $"{record.Id}-{index}.{format}");
}
private void DeleteAttachmentFile(FeedbackRecord record, FeedbackAttachmentInfo attachment)
{
var path = AttachmentPath(record, attachment.Index, attachment.Format);
if (!File.Exists(path)) return;
var size = new FileInfo(path).Length;
File.Delete(path);
_totalBytes -= size;
}
private void DeleteFile(FeedbackRecord record)
{
foreach (var attachment in record.Attachments) DeleteAttachmentFile(record, attachment);
var path = PathOf(record);
if (!File.Exists(path)) return;
var size = new FileInfo(path).Length;
@@ -414,8 +498,16 @@ public sealed class FeedbackStore
if (record.Format != FeedbackRecord.FormatName || record.Id != id ||
!string.Equals(record.GameId, gameId, StringComparison.Ordinal))
throw new InvalidDataException("contenu inattendu");
records[id] = record with { Fields = record.Fields ?? new Dictionary<string, string>() };
var attachments = (record.Attachments ?? new List<FeedbackAttachmentInfo>())
.Where(a => a.Format is FeedbackImages.Png or FeedbackImages.Jpeg && a.Index is >= 1 and <= FeedbackImages.MaxAttachments)
.ToList();
records[id] = record with { Fields = record.Fields ?? new Dictionary<string, string>(), Attachments = attachments };
total += info.Length;
foreach (var attachment in attachments)
{
var attachmentPath = Path.Combine(month, $"{id}-{attachment.Index}.{attachment.Format}");
if (File.Exists(attachmentPath)) total += new FileInfo(attachmentPath).Length;
}
}
catch (Exception ex) when (ex is JsonException or InvalidDataException or IOException or UnauthorizedAccessException)
{
@@ -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.
}
}
}
@@ -33,6 +33,18 @@ internal static class SecureFiles
File.Move(temp, path, overwrite: true); // renommage : atomique sur le même système de fichiers
}
/// <summary>Écrit un fichier binaire de façon atomique (fichier temporaire 0600 puis renommage).</summary>
public static void WriteAllBytesAtomic(string path, ReadOnlySpan<byte> bytes)
{
var temp = path + ".tmp";
using (var stream = new FileStream(temp, CreateOptions(System.IO.FileMode.Create)))
{
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
File.Move(temp, path, overwrite: true);
}
/// <summary>Crée un fichier uniquement s'il n'existe pas encore (faux s'il existait).</summary>
public static bool TryCreateNew(string path, string content)
{
+1 -1
View File
@@ -63,7 +63,7 @@
"RequestsPerMinute": 10,
"MaxPerGamePerDay": 300,
"MaxPerGame": 5000,
"MaxTotalMegabytes": 100,
"MaxTotalMegabytes": 1024,
"MinFreeDiskMegabytes": 500,
"RetentionClosedMonths": 12,
"RetentionMaxMonths": 24
+102 -6
View File
@@ -286,8 +286,11 @@ Limites :
textes) ;
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
et au moins celui du dernier trousseau qu'il a vu ;
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
* un appareil ajouté au trousseau, ou une autorisation ajoutée à un appareil, pendant qu'un launcher tourne est
connu de ce launcher dès le contenu suivant signé par cet appareil (nouveau téléchargement du trousseau, au plus une
fois par minute) ;
* un launcher laissé ouvert retélécharge le trousseau toutes les 30 minutes au plus (à la vérification suivante) : un
appareil retiré, ou une autorisation enlevée, n'y est plus accepté sans attendre son redémarrage.
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
@@ -372,8 +375,10 @@ d'écriture ouverte à tous** : elle est désactivée tant que tu ne l'actives p
Protections en place : défi anti-spam à résoudre par le launcher (preuve de calcul, une fraction de seconde), 3
demandes par 10 minutes et 10 par jour par adresse IP (comptées **en mémoire seulement**), 300 demandes par jour et
par jeu, 5 000 demandes et 100 Mo au plus, 500 Mo d'espace disque libre minimum, corps de 16 Ko, contrôle complet du
contenu (caractères invisibles refusés). **Aucune adresse IP** n'est enregistrée avec une demande ni dans l'audit de
par jeu, 5 000 demandes et 1 Go au plus (captures comprises), 500 Mo d'espace disque libre minimum, corps de 16 Ko,
contrôle complet du contenu (caractères invisibles refusés). **Captures d'écran** : 3 par demande, PNG ou JPEG de
4 Mo au plus, envoyées avec un jeton remis au seul launcher qui a envoyé la demande (15 min) ; le serveur vérifie
que ce sont de vraies images (structure complète) et **retire leurs métadonnées** (EXIF, position GPS…). **Aucune adresse IP** n'est enregistrée avec une demande ni dans l'audit de
son envoi ; nginx n'écrit pas ces requêtes dans ses journaux.
1. Active la réception dans `/opt/launcher-api/appsettings.Production.json` (section `LauncherApi`) :
@@ -390,8 +395,8 @@ son envoi ; nginx n'écrit pas ces requêtes dans ses journaux.
```
limit_req_zone $binary_remote_addr zone=feedback_par_ip:10m rate=6r/m;
```
puis colle les **deux** blocs `location = /api/v1/feedback…` de `server/deploy/nginx-api.conf` à côté du bloc
`/api/`, et `sudo nginx -t && sudo systemctl reload nginx`.
puis colle les **trois** blocs `location … /api/v1/feedback…` de `server/deploy/nginx-api.conf` (défi, envoi,
captures d'écran à 5 Mo) à côté du bloc `/api/`, et `sudo nginx -t && sudo systemctl reload nginx`.
3. Redémarre le service : `sudo systemctl restart launcher-api`.
4. **Droits des administrateurs existants** : le groupe **Propriétaire** reçoit automatiquement les nouvelles
permissions. Pour un serveur déjà installé, les autres groupes ne changent pas tout seuls (aucune élévation de droits
@@ -413,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 :
@@ -540,9 +628,17 @@ recréer les administrateurs.
├─ manifest-history/<jeu>/ manifests remplacés (notes de version, publications ; 20 derniers)
├─ 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é)
+43 -1
View File
@@ -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
# =============================================================================
@@ -99,3 +100,44 @@ location = /api/v1/feedback {
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
# Captures d'écran d'une demande (jeton d'envoi remis au seul launcher qui a envoyé la demande) : 4 Mo par image.
location ^~ /api/v1/feedback/attachments/ {
limit_req zone=feedback_par_ip burst=6 nodelay;
limit_req_status 429;
access_log off;
client_max_body_size 5m;
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 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;
}
+20 -1
View File
@@ -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;
}
+60 -2
View File
@@ -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,62 @@ 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,
signed: false, ct, maxResponseBytes: FeedbackImages.MaxBytes);
private static string CheckFeedbackId(string id) =>
FeedbackRules.IsValidId(id) ? id : throw new ArgumentException("Identifiant de demande invalide.", nameof(id));
@@ -319,7 +376,8 @@ public sealed class AdminSession : IAsyncDisposable
SendCoreAsync<T>(method, route, body, signed: true, ct, contentType, timeout);
private async Task<T> SendCoreAsync<T>(HttpMethod method, string route, byte[]? body, bool signed, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null)
string contentType = "application/json", TimeSpan? timeout = null,
long maxResponseBytes = ApiJson.MaxResponseBytes)
{
var token = _token ?? throw new ApiException(401, ApiErrorCodes.Unauthorized, "Session fermée : reconnectez-vous.");
@@ -345,7 +403,7 @@ public sealed class AdminSession : IAsyncDisposable
request.Headers.Add(SignedRequestHeaders.Nonce, s.Nonce);
request.Headers.Add(SignedRequestHeaders.Signature, Convert.ToBase64String(s.Signature));
}
}, ct, contentType, timeout).ConfigureAwait(false);
}, ct, contentType, timeout, maxResponseBytes).ConfigureAwait(false);
}
catch (ApiException ex) when (ex.Status == 401 && ex.Code == ApiErrorCodes.Unauthorized)
{
@@ -113,7 +113,49 @@ public sealed class LauncherApiClient : IDisposable
/// Envoie une demande de joueur (bouton « Aide ») : défi anti-spam demandé au serveur, résolu sur ce PC (une fraction de
/// seconde, hors du fil de l'interface), puis envoi. Aucune session, aucune clé, aucun identifiant du PC.
/// </summary>
public async Task<FeedbackSubmitted> SendFeedbackAsync(FeedbackSubmission submission, CancellationToken ct = default)
public async Task<FeedbackSubmitted> SendFeedbackAsync(FeedbackSubmission submission, CancellationToken ct = default) =>
(await SendFeedbackAsync(submission, Array.Empty<byte[]>(), ct).ConfigureAwait(false)).Submitted;
/// <summary>
/// Envoie une demande puis ses captures d'écran (déjà nettoyées par <see cref="FeedbackImages.Sanitize"/>), une par
/// une, avec le jeton remis par le serveur. Une capture refusée ou perdue n'annule pas la demande : le résultat
/// indique combien ont été reçues et pourquoi les autres ne l'ont pas été.
/// </summary>
public async Task<FeedbackSendResult> SendFeedbackAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments,
CancellationToken ct = default)
{
if (attachments.Count > FeedbackImages.MaxAttachments) throw new ArgumentException("Trop de captures.", nameof(attachments));
var submitted = await SendSubmissionAsync(submission with { AttachmentCount = attachments.Count }, ct).ConfigureAwait(false);
var sent = 0;
string? error = null;
for (var i = 0; i < attachments.Count && submitted.UploadToken is { } token; i++)
{
var index = i + 1;
for (var attempt = 1; ; attempt++)
{
try
{
await SendAsync<FeedbackAttachmentStored>(HttpMethod.Put, ApiRoutes.FeedbackAttachment(index), attachments[i],
request => request.Headers.Add(FeedbackUpload.TokenHeader, token), ct, FeedbackUpload.ContentType,
TimeSpan.FromMinutes(2)).ConfigureAwait(false);
sent++;
break;
}
catch (ApiException ex) when (ex.Status == 0 && attempt < 2)
{
// coupure : un nouvel essai (même numéro : la capture est remplacée si elle était arrivée)
}
catch (ApiException ex)
{
error = ex.Message;
break;
}
}
}
return new FeedbackSendResult(submitted, sent, error);
}
private async Task<FeedbackSubmitted> SendSubmissionAsync(FeedbackSubmission submission, CancellationToken ct)
{
var challenge = await SendAsync<FeedbackChallenge>(HttpMethod.Post, ApiRoutes.FeedbackChallenge, null, null, ct).ConfigureAwait(false);
if (challenge.Difficulty is < 0 or > FeedbackProof.MaxDifficulty || !Base64Url.TryDecode(challenge.Nonce, out var nonce) ||
@@ -149,7 +191,8 @@ public sealed class LauncherApiClient : IDisposable
internal async Task<T> SendAsync<T>(HttpMethod method, string route, byte[]? body,
Action<HttpRequestMessage>? prepare, CancellationToken ct,
string contentType = "application/json", TimeSpan? timeout = null)
string contentType = "application/json", TimeSpan? timeout = null,
long maxResponseBytes = ApiJson.MaxResponseBytes)
{
using var request = new HttpRequestMessage(method, Resolve(route));
if (body is not null)
@@ -182,7 +225,7 @@ public sealed class LauncherApiClient : IDisposable
byte[] bytes;
try
{
bytes = await ReadBoundedAsync(response, deadline.Token).ConfigureAwait(false);
bytes = await ReadBoundedAsync(response, deadline.Token, maxResponseBytes).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!ct.IsCancellationRequested)
{
@@ -194,6 +237,7 @@ public sealed class LauncherApiClient : IDisposable
}
if (!response.IsSuccessStatusCode) throw ToException(response, bytes);
if (typeof(T) == typeof(NoContent)) return (T)(object)NoContent.Value;
if (typeof(T) == typeof(byte[])) return (T)(object)bytes; // contenu binaire (capture d'écran…)
try
{
var value = JsonSerializer.Deserialize<T>(bytes, ApiJson.Options)
@@ -207,9 +251,9 @@ public sealed class LauncherApiClient : IDisposable
}
}
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct)
private static async Task<byte[]> ReadBoundedAsync(HttpResponseMessage response, CancellationToken ct, long maxBytes)
{
if (response.Content.Headers.ContentLength > ApiJson.MaxResponseBytes)
if (response.Content.Headers.ContentLength > maxBytes)
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
using var buffer = new MemoryStream();
@@ -217,7 +261,7 @@ public sealed class LauncherApiClient : IDisposable
int read;
while ((read = await stream.ReadAsync(chunk, ct).ConfigureAwait(false)) > 0)
{
if (buffer.Length + read > ApiJson.MaxResponseBytes)
if (buffer.Length + read > maxBytes)
throw new ApiException((int)response.StatusCode, "invalid_response", "Réponse du serveur trop volumineuse.");
buffer.Write(chunk, 0, read);
}
@@ -279,3 +323,8 @@ public sealed class NoContent
public static readonly NoContent Value = new();
private NoContent() { }
}
/// <summary>Résultat de l'envoi d'une demande et de ses captures d'écran.</summary>
/// <param name="AttachmentsSent">Captures reçues par le serveur.</param>
/// <param name="AttachmentError">Raison de l'échec de la première capture non reçue (null : toutes reçues).</param>
public sealed record FeedbackSendResult(FeedbackSubmitted Submitted, int AttachmentsSent, string? AttachmentError);
+17
View File
@@ -115,9 +115,17 @@ public static class ApiRoutes
/// <summary>Envoi d'une demande par un joueur (anonyme, corps de 16 Ko au plus).</summary>
public const string Feedback = "v1/feedback";
/// <summary>Capture d'écran d'une demande (anonyme, jeton d'envoi ; corps : l'image, 4 Mo au plus).</summary>
public const string FeedbackAttachmentPattern = "v1/feedback/attachments/{index:int}";
public static string FeedbackAttachment(int index) => $"v1/feedback/attachments/{index}";
/// <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}";
@@ -134,6 +142,15 @@ public static class ApiRoutes
/// <summary>Une demande (identifiant : 32 caractères hexadécimaux, rien à encoder).</summary>
public static string GameFeedbackItem(string gameId, string id) => $"v1/games/{gameId}/feedback/{id}";
public const string GameFeedbackAttachmentPattern = "v1/games/{gameId}/feedback/{id}/attachments/{index:int}";
/// <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>
@@ -49,6 +56,9 @@ public sealed record FeedbackSubmission
public FeedbackTechInfo? Tech { get; init; }
/// <summary>Captures d'écran annoncées (0 à <see cref="FeedbackImages.MaxAttachments"/>), envoyées ensuite une par une.</summary>
public int AttachmentCount { get; init; }
/// <summary>Défi résolu (<see cref="FeedbackChallenge.ChallengeId"/>) et sa solution.</summary>
public string ChallengeId { get; init; } = "";
@@ -59,6 +69,40 @@ public sealed record FeedbackSubmission
public sealed record FeedbackSubmitted
{
public string Reference { get; init; } = "";
/// <summary>
/// Jeton d'envoi des captures annoncées (null sans capture) : à joindre à chaque envoi (en-tête
/// <see cref="FeedbackUpload.TokenHeader"/>). Valable <see cref="FeedbackUpload.TokenLifetimeMinutes"/> minutes,
/// seulement pour cette demande et le nombre de captures annoncé ; connu du seul launcher qui a envoyé la demande.
/// </summary>
public string? UploadToken { get; init; }
}
/// <summary>Envoi des captures d'écran d'une demande (PUT v1/feedback/attachments/{index}, corps : l'image).</summary>
public static class FeedbackUpload
{
public const string TokenHeader = "X-GL-Feedback-Upload";
public const int TokenLifetimeMinutes = 15;
public const string ContentType = "application/octet-stream";
}
/// <summary>Capture d'écran enregistrée (nettoyée de ses métadonnées par le serveur).</summary>
public sealed record FeedbackAttachmentInfo
{
/// <summary>Numéro (1 à <see cref="FeedbackImages.MaxAttachments"/>).</summary>
public int Index { get; init; }
/// <summary>« png » ou « jpg ».</summary>
public string Format { get; init; } = "";
public int Width { get; init; }
public int Height { get; init; }
public long Bytes { get; init; }
}
/// <summary>Capture reçue (réponse du serveur).</summary>
public sealed record FeedbackAttachmentStored
{
public int Index { get; init; }
}
// ------------------------------------------------------------------ Menu d'administration
@@ -106,6 +150,13 @@ public sealed record FeedbackSummary
public string? Badge { get; init; }
public bool HasNote { get; init; }
/// <summary>Le titre ou un champ contient un lien ou une adresse (<see cref="FeedbackLinks"/>) : prudence.</summary>
public bool HasLinks { get; init; }
/// <summary>Captures d'écran reçues.</summary>
public int AttachmentCount { get; init; }
public long Revision { get; init; }
}
@@ -126,9 +177,22 @@ 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; }
/// <summary>Captures d'écran reçues (contenu : GET …/attachments/{index}).</summary>
public IReadOnlyList<FeedbackAttachmentInfo> Attachments { get; init; } = Array.Empty<FeedbackAttachmentInfo>();
/// <summary>Note interne des administrateurs (jamais visible du joueur).</summary>
public string? AdminNote { 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.&lt;clé&gt; »).</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.&lt;clé&gt; » 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;
}
}
@@ -0,0 +1,333 @@
using System.Buffers.Binary;
using System.IO.Compression;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Captures d'écran jointes à une demande : contrôle ET nettoyage, mêmes règles au launcher (avant l'envoi), au serveur
/// (refus de tout fichier non conforme) et dans le menu d'administration (avant l'affichage).
/// • <b>PNG ou JPEG uniquement</b>, reconnus à leur contenu, jamais à l'extension. Le launcher convertit les autres
/// formats courants (BMP, GIF, WebP) en PNG avant l'envoi ;
/// • <b>vraie image</b> : structure complète vérifiée. PNG : blocs et CRC, en-tête cohérent, palette si nécessaire,
/// données décompressées de la taille exacte de l'image, filtres de lignes valides. JPEG : segments, tables,
/// une trame baseline ou progressive, scans et fin d'image ;
/// • dimensions bornées (<see cref="MaxSide"/>, <see cref="MaxPixels"/>), taille bornée (<see cref="MaxBytes"/>),
/// aucune donnée après la fin de l'image, décompression interrompue au-delà de la taille attendue (bombe zlib) ;
/// • <b>métadonnées retirées</b> : PNG : blocs texte, eXIf, date… (seuls IHDR, PLTE, tRNS, IDAT, IEND sont
/// gardés) ; JPEG : EXIF (position GPS, appareil…), XMP, commentaires… (seuls JFIF et Adobe sont gardés).
/// </summary>
public static class FeedbackImages
{
public const int MaxAttachments = 3;
public const int MaxBytes = 4 * 1024 * 1024;
/// <summary>Corps d'une requête d'envoi d'une capture (image + marge) ; voir nginx-api.conf.</summary>
public const int MaxRequestBytes = MaxBytes + 16 * 1024;
public const int MinSide = 16;
public const int MaxSide = 4096;
public const long MaxPixels = 4096L * 2304;
/// <summary>Le launcher réduit les captures plus grandes à cette taille (côté le plus long) avant l'envoi.</summary>
public const int TargetMaxSide = 3840;
public const string Png = "png";
public const string Jpeg = "jpg";
/// <summary>Formats acceptés en entrée par le launcher (convertis en PNG/JPEG avant l'envoi).</summary>
public static readonly IReadOnlyList<string> InputExtensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" };
public sealed record ImageInfo(string Format, int Width, int Height);
/// <summary>Format reconnu au contenu parmi les formats d'entrée du launcher (null : autre chose).</summary>
public static string? DetectInputFormat(ReadOnlySpan<byte> data)
{
if (data.StartsWith(PngSignature)) return Png;
if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) return Jpeg;
if (data.Length >= 6 && (data[..6].SequenceEqual("GIF87a"u8) || data[..6].SequenceEqual("GIF89a"u8))) return "gif";
if (data.Length >= 14 && data[0] == (byte)'B' && data[1] == (byte)'M') return "bmp";
if (data.Length >= 12 && data[..4].SequenceEqual("RIFF"u8) && data.Slice(8, 4).SequenceEqual("WEBP"u8)) return "webp";
return null;
}
/// <summary>
/// Contrôle complet et nettoyage. Null si l'image est acceptée (<paramref name="clean"/> : l'image sans
/// métadonnées, à enregistrer ou afficher à la place de l'original), sinon la raison du refus (français).
/// </summary>
public static string? Sanitize(ReadOnlySpan<byte> data, out ImageInfo? info, out byte[]? clean)
{
info = null;
clean = null;
if (data.Length == 0) return "Fichier vide.";
if (data.Length > MaxBytes) return $"Image trop volumineuse : {MaxBytes / 1024 / 1024} Mo au plus.";
string? error;
if (data.StartsWith(PngSignature)) (info, clean, error) = SanitizePng(data);
else if (data.Length >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) (info, clean, error) = SanitizeJpeg(data);
else return "Ce fichier n'est pas une image PNG ou JPEG.";
if (error is not null)
{
info = null;
clean = null;
return error;
}
if (info!.Width < MinSide || info.Height < MinSide || info.Width > MaxSide || info.Height > MaxSide ||
(long)info.Width * info.Height > MaxPixels)
{
var refused = info;
info = null;
clean = null;
return $"Dimensions refusées : {refused.Width} × {refused.Height} px ({MinSide} à {MaxSide} px de côté).";
}
return null;
}
// ------------------------------------------------------------------ PNG
private static ReadOnlySpan<byte> PngSignature => new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
private static (ImageInfo?, byte[]?, string?) SanitizePng(ReadOnlySpan<byte> data)
{
const string invalid = "Image PNG invalide ou incomplète.";
var output = new MemoryStream(data.Length);
output.Write(PngSignature);
var idat = new MemoryStream();
int width = 0, height = 0, depth = 0, colorType = 0, interlace = 0;
bool header = false, palette = false, ended = false, idatDone = false, inIdat = false;
var pos = 8;
while (pos < data.Length)
{
if (ended) return (null, null, "Image PNG suivie de données étrangères à l'image.");
if (pos + 12 > data.Length) return (null, null, invalid);
var length = BinaryPrimitives.ReadUInt32BigEndian(data[pos..]);
if (length > int.MaxValue || pos + 12 + (long)length > data.Length) return (null, null, invalid);
var type = data.Slice(pos + 4, 4);
foreach (var b in type)
if (b is not ((>= (byte)'A' and <= (byte)'Z') or (>= (byte)'a' and <= (byte)'z'))) return (null, null, invalid);
var body = data.Slice(pos + 8, (int)length);
var crc = BinaryPrimitives.ReadUInt32BigEndian(data[(pos + 8 + (int)length)..]);
if (Crc32(type, body) != crc) return (null, null, "Image PNG endommagée (somme de contrôle incorrecte).");
var chunk = data.Slice(pos, 12 + (int)length);
pos += 12 + (int)length;
var name = System.Text.Encoding.ASCII.GetString(type);
if (!header && name != "IHDR") return (null, null, invalid);
if (inIdat && name != "IDAT") idatDone = true;
inIdat = false;
switch (name)
{
case "IHDR":
if (header || length != 13) return (null, null, invalid);
header = true;
var w = BinaryPrimitives.ReadUInt32BigEndian(body);
var h = BinaryPrimitives.ReadUInt32BigEndian(body[4..]);
if (w is 0 or > MaxSide || h is 0 or > MaxSide)
return (null, null, $"Dimensions refusées ({MinSide} à {MaxSide} px de côté).");
(width, height, depth, colorType, interlace) = ((int)w, (int)h, body[8], body[9], body[12]);
var validDepth = colorType switch
{
0 => depth is 1 or 2 or 4 or 8 or 16,
3 => depth is 1 or 2 or 4 or 8,
2 or 4 or 6 => depth is 8 or 16,
_ => false
};
if (!validDepth || body[10] != 0 || body[11] != 0 || interlace > 1) return (null, null, invalid);
output.Write(chunk);
break;
case "PLTE":
if (palette || idat.Length > 0 || length == 0 || length % 3 != 0 || length > 768 || colorType is 0 or 4)
return (null, null, invalid);
palette = true;
output.Write(chunk);
break;
case "tRNS":
if (idat.Length > 0) return (null, null, invalid);
output.Write(chunk);
break;
case "IDAT":
if (idatDone) return (null, null, invalid); // blocs IDAT non consécutifs
inIdat = true;
idat.Write(body);
output.Write(chunk);
break;
case "IEND":
if (length != 0 || idat.Length == 0) return (null, null, invalid);
ended = true;
output.Write(chunk);
break;
default:
// Bloc critique inconnu (majuscule) : image non standard, refusée. Bloc auxiliaire (texte, eXIf,
// date, profil…) : retiré.
if (char.IsUpper(name[0])) return (null, null, "Image PNG non standard.");
break;
}
}
if (!ended) return (null, null, "Image PNG incomplète.");
if (colorType == 3 && !palette) return (null, null, invalid);
var channels = colorType switch { 0 => 1, 2 => 3, 3 => 1, 4 => 2, _ => 4 };
var bitsPerPixel = channels * depth;
if (!CheckPixels(idat.ToArray(), width, height, bitsPerPixel, interlace == 1))
return (null, null, "Image PNG endommagée (données de l'image incorrectes).");
return (new ImageInfo(Png, width, height), output.ToArray(), null);
}
/// <summary>
/// Décompresse les données (sans dépasser la taille attendue + 1 octet) et vérifie qu'elles ont exactement la taille
/// de l'image, ligne par ligne, avec un type de filtre valide (0 à 4) au début de chaque ligne.
/// </summary>
private static bool CheckPixels(byte[] compressed, int width, int height, int bitsPerPixel, bool interlaced)
{
var rows = new List<long>(); // octets par ligne (filtre compris), dans l'ordre
if (!interlaced) AddRows(rows, width, height, bitsPerPixel);
else
{
int[] x0 = { 0, 4, 0, 2, 0, 1, 0 }, y0 = { 0, 0, 4, 0, 2, 0, 1 }, dx = { 8, 8, 4, 4, 2, 2, 1 }, dy = { 8, 8, 8, 4, 4, 2, 2 };
for (var p = 0; p < 7; p++)
{
var pw = width > x0[p] ? (width - x0[p] + dx[p] - 1) / dx[p] : 0;
var ph = height > y0[p] ? (height - y0[p] + dy[p] - 1) / dy[p] : 0;
if (pw > 0 && ph > 0) AddRows(rows, pw, ph, bitsPerPixel);
}
}
try
{
using var zlib = new ZLibStream(new MemoryStream(compressed, writable: false), CompressionMode.Decompress);
var buffer = new byte[64 * 1024];
var row = 0;
long left = rows.Count > 0 ? rows[0] : 0; // octets restants dans la ligne en cours
int read;
while ((read = zlib.Read(buffer, 0, buffer.Length)) > 0)
{
for (var i = 0; i < read;)
{
if (row >= rows.Count) return false; // plus de données que l'image
if (left == rows[row] && buffer[i] > 4) return false; // type de filtre de la ligne
var take = (int)Math.Min(left, read - i);
i += take;
left -= take;
if (left == 0 && ++row < rows.Count) left = rows[row];
}
}
return row == rows.Count;
}
catch (InvalidDataException)
{
return false;
}
}
private static void AddRows(List<long> rows, int width, int height, int bitsPerPixel)
{
var bytesPerRow = 1 + ((long)width * bitsPerPixel + 7) / 8;
for (var y = 0; y < height; y++) rows.Add(bytesPerRow);
}
private static readonly uint[] CrcTable = BuildCrcTable();
private static uint[] BuildCrcTable()
{
var table = new uint[256];
for (uint n = 0; n < 256; n++)
{
var c = n;
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
table[n] = c;
}
return table;
}
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;
}
// ------------------------------------------------------------------ JPEG
private static (ImageInfo?, byte[]?, string?) SanitizeJpeg(ReadOnlySpan<byte> data)
{
const string invalid = "Image JPEG invalide ou incomplète.";
var output = new MemoryStream(data.Length);
output.Write(data[..2]); // SOI
int width = 0, height = 0, scans = 0;
bool frame = false, tables = false, ended = false;
var pos = 2;
while (pos < data.Length)
{
if (ended) return (null, null, "Image JPEG suivie de données étrangères à l'image.");
if (pos + 2 > data.Length || data[pos] != 0xFF) return (null, null, invalid);
var marker = data[pos + 1];
if (marker == 0xFF)
{
pos++; // octet de remplissage
continue;
}
if (marker == 0xD9)
{
if (!frame || scans == 0) return (null, null, invalid);
output.Write(data.Slice(pos, 2));
pos += 2;
ended = true;
continue;
}
if (marker is 0xD8 or 0x01 or (>= 0xD0 and <= 0xD7)) return (null, null, invalid);
if (pos + 4 > data.Length) return (null, null, invalid);
var length = BinaryPrimitives.ReadUInt16BigEndian(data[(pos + 2)..]);
if (length < 2 || pos + 2 + length > data.Length) return (null, null, invalid);
var segment = data.Slice(pos, 2 + length);
var body = data.Slice(pos + 4, length - 2);
pos += 2 + length;
switch (marker)
{
case 0xE0: // APP0 (JFIF) : gardé
case 0xEE: // APP14 (Adobe : transformation des couleurs) : gardé
output.Write(segment);
break;
case >= 0xE1 and <= 0xEF: // APP1 à APP15 : EXIF (position, appareil), XMP, profils… : retirés
case 0xFE: // commentaire : retiré
break;
case 0xDB: // tables de quantification
case 0xC4: // tables de Huffman
case 0xDD: // intervalle de resynchronisation
if (marker != 0xDD) tables = true;
output.Write(segment);
break;
case 0xC0 or 0xC1 or 0xC2: // trame baseline, étendue ou progressive (Huffman)
if (frame || body.Length < 6) return (null, null, invalid);
frame = true;
if (body[0] != 8) return (null, null, "Image JPEG non prise en charge (précision).");
height = BinaryPrimitives.ReadUInt16BigEndian(body[1..]);
width = BinaryPrimitives.ReadUInt16BigEndian(body[3..]);
var components = body[5];
if (width == 0 || height == 0 || components is not (1 or 3) || body.Length != 6 + 3 * components)
return (null, null, invalid);
output.Write(segment);
break;
case 0xDA: // début d'un scan : en-tête, puis données compressées jusqu'au marqueur suivant
if (!frame || !tables || body.Length < 6) return (null, null, invalid);
scans++;
output.Write(segment);
var start = pos;
while (true)
{
if (pos + 1 >= data.Length) return (null, null, "Image JPEG incomplète.");
if (data[pos] == 0xFF && data[pos + 1] != 0x00 && data[pos + 1] is not (>= 0xD0 and <= 0xD7) && data[pos + 1] != 0xFF)
break;
pos++;
}
if (pos == start) return (null, null, invalid); // scan vide
output.Write(data[start..pos]);
break;
default:
// Autres trames (sans perte, arithmétiques, hiérarchiques), marqueurs réservés : refusés.
return (null, null, "Image JPEG non prise en charge.");
}
}
if (!ended) return (null, null, "Image JPEG incomplète.");
return (new ImageInfo(Jpeg, width, height), output.ToArray(), null);
}
}
@@ -0,0 +1,149 @@
using System.Text;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Repère les liens et adresses dans le texte d'une demande (envoyé par un inconnu) pour avertir l'administrateur
/// qui la lit : un lien peut mener à un site d'hameçonnage ou à un fichier malveillant. Rien n'est jamais rendu
/// cliquable ; l'avertissement montre les liens « désamorcés » (<see cref="Defang"/>).
///
/// Formes reconnues, y compris déguisées (« hxxp », « http[:]// », « [.] », « (dot) », caractères pleine chasse) :
/// • adresses avec schéma : http://, https://, ftp://, file://, ws://… et toute forme « schéma:// » ;
/// • schémas dangereux sans « // » : javascript:, data:, vbscript:, file:, mailto:, ms-…:, search-ms:, steam:… ;
/// • « www. » suivi d'un nom ;
/// • noms de domaine nus avec une extension courante (« exemple.com/page », « bit.ly/x », « discord.gg/… ») ;
/// • adresses IP (avec port ou chemin éventuels) ; chemins réseau Windows (\\serveur\partage) ;
/// • adresses e-mail.
/// </summary>
public static partial class FeedbackLinks
{
/// <summary>Liens rapportés au plus (le nombre total reste exact).</summary>
public const int MaxReported = 10;
// « . » collé, ou déguisé : « [.] », « (.) », « {.} », « [dot] », « (dot) » (espaces permis autour), « dot » entre espaces.
private const string Dot = @"(?:\.|\s*(?:\[\.\]|\(\.\)|\{\.\}|\[dot\]|\(dot\))\s*|\s+dot\s+)";
private const string Tlds =
"com|net|org|info|biz|xyz|top|site|online|app|dev|io|gg|co|me|tv|cc|ly|to|ai|gl|link|click|live|shop|store|" +
"page|zip|mov|pw|tk|ml|ga|cf|gq|su|ru|cn|fr|be|ch|ca|de|uk|eu|us|es|it|nl|pl|pt|br|in|jp|kr|au|io|lu|re|tech|" +
"space|website|club|fun|icu|win|bid|work|ink|lol|zone|cloud|download|stream|games|game|social|chat|email";
[GeneratedRegex(@"\b[a-z][a-z0-9+.\-]{0,20}\s*(?::|\[:\])\s*(?:/|\\){2}\S+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex SchemeUrl();
[GeneratedRegex(@"\b(?:javascript|vbscript|data|file|mailto|steam|discord|tel|sms|skype|magnet|ms-[a-z\-]+|search-ms|shell)\s*:\s*\S+",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex DangerousScheme();
[GeneratedRegex(@"\bh(?:xx|\*\*|XX)ps?\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex DisguisedHttp();
[GeneratedRegex(@"\bwww\d{0,3}\s*" + Dot + @"\s*[a-z0-9\-]+\S*", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex Www();
[GeneratedRegex(@"\b[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?(?:" + Dot + @"[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)*" + Dot +
"(?:" + Tlds + @")\b(?:[/:?#]\S*)?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex BareDomain();
[GeneratedRegex(@"\b(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:" + Dot + @"(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b(?::\d{1,5})?(?:/\S*)?",
RegexOptions.CultureInvariant)]
private static partial Regex IpAddress();
[GeneratedRegex(@"\\\\[a-z0-9.\-_$]+\\\S+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex UncPath();
[GeneratedRegex(@"\b[a-z0-9._%+\-]+\s*(?:@|\[at\]|\(at\))\s*[a-z0-9\-]+(?:" + Dot + @"[a-z0-9\-]+)+\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex Email();
/// <summary>Le texte contient-il au moins un lien ou une adresse ?</summary>
public static bool Contains(string? text) => Find(text).Count > 0;
/// <summary>Liens et adresses trouvés (sans doublon, dans l'ordre d'apparition, <see cref="MaxReported"/> au plus).</summary>
public static IReadOnlyList<string> Find(string? text) => Find(text, out _);
public static IReadOnlyList<string> Find(string? text, out int total)
{
total = 0;
if (string.IsNullOrWhiteSpace(text)) return Array.Empty<string>();
var normalized = Normalize(text);
var found = new List<(int Start, int End, string Value)>();
void Collect(Regex regex)
{
foreach (Match match in regex.Matches(normalized))
{
var value = TrimEnd(match.Value);
if (value.Length == 0) continue;
var end = match.Index + value.Length;
if (found.Any(f => match.Index < f.End && end > f.Start)) continue; // déjà couvert par un motif plus large
found.Add((match.Index, end, value));
}
}
Collect(SchemeUrl());
Collect(DangerousScheme());
Collect(UncPath());
Collect(Email());
Collect(Www());
Collect(IpAddress());
Collect(BareDomain());
Collect(DisguisedHttp());
var distinct = found.OrderBy(f => f.Start).Select(f => f.Value).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
total = distinct.Count;
return distinct.Take(MaxReported).ToList();
}
/// <summary>
/// Forme « désamorcée » à afficher (« hxxps[://]exemple[.]com ») : ni cliquable, ni ouverte par un copier-coller
/// distrait dans la barre d'adresse.
/// </summary>
public static string Defang(string link)
{
var text = link.Length > 120 ? link[..117] + "…" : link;
text = Regex.Replace(text, @"(?i)\bhttp", "hxxp");
text = Regex.Replace(text, @"(?i)\bftp", "fxp");
return text.Replace("://", "[://]").Replace(".", "[.]").Replace("@", "[@]");
}
/// <summary>
/// Caractères pleine chasse (« https:// ») ramenés à leur équivalent ASCII, et « ⁄ ∕ / » à « / » : les
/// déguisements les plus courants ne passent pas inaperçus. (Pas de normalisation Unicode complète : indisponible
/// sur le serveur en mode « culture invariante » ; les caractères invisibles sont déjà refusés à l'envoi.)
/// </summary>
private static string Normalize(string text)
{
var sb = new StringBuilder(text.Length);
foreach (var c in text)
sb.Append(c switch
{
>= '!' and <= '~' => (char)(c - 0xFEE0),
'。' or '。' or '․' => '.', // points idéographique, demi-chasse, « point de conduite »
'⁄' or '∕' => '/',
_ => c
});
return sb.ToString();
}
/// <summary>Ponctuation de fin de phrase retirée ; une parenthèse ou un crochet fermant seulement s'il n'est pas ouvert dans le lien.</summary>
private static string TrimEnd(string value)
{
while (value.Length > 0)
{
var last = value[^1];
var trim = last switch
{
'.' or ',' or ';' or ':' or '!' or '?' or '»' or '"' or '\'' or '>' or '…' => true,
')' => value.Count(c => c == '(') < value.Count(c => c == ')'),
']' => value.Count(c => c == '[') < value.Count(c => c == ']'),
'}' => value.Count(c => c == '{') < value.Count(c => c == '}'),
_ => false
};
if (!trim) break;
value = value[..^1];
}
return value;
}
}
@@ -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)
@@ -109,6 +120,9 @@ public static partial class FeedbackRules
}
if (total > MaxTotalLength) errors.Add($"Demande trop longue ({total} caractères, {MaxTotalLength} au plus).");
if (submission.AttachmentCount is < 0 or > FeedbackImages.MaxAttachments)
errors.Add($"Captures d'écran : {FeedbackImages.MaxAttachments} au plus.");
var tech = CheckTech(submission.Tech, errors);
return errors.Count > 0 ? null : submission with
{
@@ -131,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)
{
@@ -140,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;
}
}
@@ -50,9 +50,58 @@ public sealed partial class LauncherRelease
/// </summary>
public List<ChangelogEntry> Changelog { get; set; } = new();
/// <summary>
/// Version PRIORITAIRE la plus récente publiée jusqu'à celle-ci incluse (ex. "3.4.0"), ou null s'il n'y en a pas.
/// Un launcher plus ancien que cette version installe la mise à jour au démarrage MÊME SI le joueur a désactivé
/// la mise à jour automatique (correctif critique). Le réglage du joueur (self-update.json) n'est jamais modifié :
/// une fois le launcher à jour, il n'est plus concerné (effet ponctuel).
/// Reprise d'une publication à l'autre par LauncherPublisher (voir <see cref="ResolvePriorityVersion"/>) : une
/// version normale publiée après un correctif critique l'impose toujours aux launchers restés trop anciens.
/// Fait partie du contenu SIGNÉ : un serveur compromis ne peut ni l'ajouter, ni la retirer, ni la modifier.
/// Ignorée par les launchers antérieurs à 3.4.0 (propriété inconnue).
/// </summary>
public string? PriorityVersion { get; set; }
[JsonIgnore]
public Version ParsedVersion => VersionUtil.Parse(Version);
/// <summary>Cette version a elle-même été publiée comme prioritaire (option --priority).</summary>
[JsonIgnore]
public bool IsPriorityRelease => TryGetPriorityVersion(out var priority) && priority == ParsedVersion;
/// <summary>
/// Vrai si un launcher en version <paramref name="currentVersion"/> doit installer cette mise à jour même avec la
/// mise à jour automatique désactivée : il est plus ancien que la dernière version prioritaire publiée.
/// </summary>
public bool IsPriorityFor(Version currentVersion) =>
TryGetPriorityVersion(out var priority) && priority > currentVersion;
/// <summary>Version prioritaire lisible (null ou illisible : faux).</summary>
public bool TryGetPriorityVersion(out Version priority)
{
priority = new Version(0, 0, 0);
return !string.IsNullOrWhiteSpace(PriorityVersion) && VersionUtil.TryParse(PriorityVersion, out priority);
}
/// <summary>
/// Version prioritaire à inscrire dans une nouvelle publication (LauncherPublisher) :
/// <list type="bullet">
/// <item><paramref name="markPriority"/> : la version publiée elle-même ;</item>
/// <item><paramref name="clearPriority"/> : aucune (priorité publiée par erreur, par exemple) ;</item>
/// <item>sinon : celle de la publication précédente (<paramref name="previous"/>, dont la signature a été
/// VÉRIFIÉE par l'appelant), si elle n'est pas supérieure à la version publiée.</item>
/// </list>
/// </summary>
public static string? ResolvePriorityVersion(Version published, bool markPriority, bool clearPriority, LauncherRelease? previous)
{
if (markPriority && clearPriority)
throw new ArgumentException("Une publication ne peut pas à la fois être prioritaire et retirer la priorité.");
if (markPriority) return VersionUtil.Display(published);
if (clearPriority || previous is null || !previous.TryGetPriorityVersion(out var carried) || carried > published)
return null;
return VersionUtil.Display(carried);
}
public void Validate()
{
if (Format != 1)
@@ -67,6 +116,12 @@ public sealed partial class LauncherRelease
throw new UpdateSecurityException("Description de mise à jour invalide : taille incorrecte.");
if (!Sha256Regex().IsMatch(Sha256 ?? ""))
throw new UpdateSecurityException("Description de mise à jour invalide : empreinte SHA-256 incorrecte.");
if (string.IsNullOrWhiteSpace(PriorityVersion)) PriorityVersion = null;
else if (!VersionUtil.TryParse(PriorityVersion, out var priority))
throw new UpdateSecurityException($"Description de mise à jour invalide : version prioritaire « {PriorityVersion} » illisible.");
else if (priority > ParsedVersion)
throw new UpdateSecurityException(
$"Description de mise à jour invalide : version prioritaire {PriorityVersion} supérieure à la version publiée ({Version}).");
Notes ??= new List<string>();
// Textes affichés tels quels dans l'interface : mêmes bornes et nettoyage que les notes du jeu.
Changelog = ChangelogLimits.Normalize(Changelog);
@@ -10,6 +10,12 @@ public sealed record LauncherUpdateCheck(Version CurrentVersion, LauncherRelease
public bool IsAvailable => Release is not null && DownloadUri is not null;
public string CurrentDisplay => VersionUtil.Display(CurrentVersion);
public string AvailableDisplay => Release is null ? "" : VersionUtil.Display(Release.ParsedVersion);
/// <summary>
/// Mise à jour PRIORITAIRE pour ce launcher (plus ancien que la dernière version prioritaire publiée) :
/// installée au démarrage même si la mise à jour automatique est désactivée. Information signée.
/// </summary>
public bool IsPriority => IsAvailable && Release!.IsPriorityFor(CurrentVersion);
}
/// <summary>
@@ -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
@@ -56,7 +56,10 @@ public sealed class AuditPanelViewModel : AdminPanelViewModel
new("changelog.", "Notes de version"),
new("release.", "Publications"),
new("files.", "Fichiers du serveur"),
new("audit.", "Consultations et exports du journal"),
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é")
};
Results = new List<Choice>
@@ -493,12 +496,25 @@ public static class AuditTexts
"release.start" => "Publication d'une version commencée",
"release.publish" => "Version mise en ligne",
"release.cancel" => "Publication abandonnée",
"release.sign" => "Version en ligne signée",
"keyring.publish" => "Trousseau de signature publié",
"feedback.submit" => "Demande de joueur reçue",
"feedback.attachment" => "Capture d'écran reçue",
"feedback.view" => "Demande de joueur consultée",
"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",
"audit.view" => "Journal d'audit consulté",
"audit.export" => "Journal d'audit exporté (CSV)",
"audit.suppressed" => "Tentatives refusées regroupées",
"audit.purge" => "Anciens mois du journal supprimés (conservation)",
_ => action
};
@@ -518,7 +534,7 @@ public static class AuditTexts
{
null => true,
"security" => action is "request.signature" or "audit.suppressed",
"audit." => action is "audit.view" or "audit.export",
"audit." => action is "audit.view" or "audit.export" or "audit.purge",
_ => action.StartsWith(category, StringComparison.Ordinal)
};
@@ -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;
@@ -81,6 +98,12 @@ public sealed class FeedbackItem : ObservableObject
public bool HasBadge => !string.IsNullOrEmpty(_summary.Badge);
public bool HasNote => _summary.HasNote;
/// <summary>Contient un lien ou une adresse : icône d'avertissement dans la liste.</summary>
public bool HasLinks => _summary.HasLinks;
public bool HasAttachments => _summary.AttachmentCount > 0;
public string AttachmentsToolTip => $"{_summary.AttachmentCount} capture(s) d'écran";
/// <summary>« 08/11/2026 21:14 » (heure du PC).</summary>
public string DateText => _summary.ReceivedAt.ToLocalTime().ToString("dd/MM/yyyy HH:mm", CultureInfo.InvariantCulture);
@@ -94,7 +117,81 @@ public sealed class FeedbackItem : ObservableObject
}
/// <summary>Champ d'une demande, tel qu'affiché dans la fiche (libellé du formulaire, valeur en texte brut).</summary>
public sealed record FeedbackFieldLine(string Label, string Value, bool IsLong);
/// <param name="HasLink">Le texte contient un lien ou une adresse (cadre d'avertissement).</param>
public sealed record FeedbackFieldLine(string Label, string Value, bool IsLong, bool HasLink = false);
/// <summary>
/// Capture d'écran d'une demande : chargée après la fiche, REVÉRIFIÉE (<see cref="FeedbackImages.Sanitize"/> : vraie
/// image PNG ou JPEG, sans métadonnées) avant d'être affichée, réduite pour l'aperçu, agrandie à la demande.
/// </summary>
public sealed class FeedbackAttachmentView : ObservableObject
{
private byte[]? _content;
private string? _error;
private bool _isExpanded;
public FeedbackAttachmentView(FeedbackAttachmentInfo info)
{
Info = info;
ToggleCommand = new RelayCommand(() => IsExpanded = !IsExpanded, () => HasContent);
}
public FeedbackAttachmentInfo Info { get; }
/// <summary>« Capture 1 · 1920 × 1080 · PNG · 845 Ko ».</summary>
public string Label => $"Capture {Info.Index} · {Info.Width} × {Info.Height} · {Info.Format.ToUpperInvariant()} · {Math.Max(1, Info.Bytes / 1024)} Ko";
public byte[]? Content
{
get => _content;
private set
{
if (!SetProperty(ref _content, value)) return;
OnPropertyChanged(nameof(HasContent));
OnPropertyChanged(nameof(IsLoading));
ToggleCommand.RaiseCanExecuteChanged();
}
}
public bool HasContent => _content is not null;
public string? Error
{
get => _error;
private set
{
if (!SetProperty(ref _error, value)) return;
OnPropertyChanged(nameof(HasError));
OnPropertyChanged(nameof(IsLoading));
}
}
public bool HasError => !string.IsNullOrEmpty(_error);
public bool IsLoading => _content is null && _error is null;
public bool IsExpanded
{
get => _isExpanded;
set
{
if (SetProperty(ref _isExpanded, value)) OnPropertyChanged(nameof(ToggleText));
}
}
public string ToggleText => _isExpanded ? "Réduire" : "Agrandir";
public RelayCommand ToggleCommand { get; }
/// <summary>Contenu reçu du serveur : affiché seulement s'il passe le contrôle commun.</summary>
public void Load(byte[] received)
{
var error = FeedbackImages.Sanitize(received, out var info, out var clean);
if (error is not null || info is null) Error = $"Image refusée : {error}";
else if (info.Width != Info.Width || info.Height != Info.Height) Error = "Image refusée : dimensions différentes de celles annoncées.";
else Content = clean;
}
public void Fail(string message) => Error = message;
}
/// <summary>Statut proposé dans la fiche.</summary>
public sealed record FeedbackStatusChoice(string Value, string Label);
@@ -119,8 +216,33 @@ public sealed class FeedbackDetailView : ObservableObject
_status = AllStatuses.FirstOrDefault(s => s.Value == detail.Summary.Status) ?? AllStatuses[0];
_note = detail.AdminNote ?? "";
Lines = BuildLines(detail);
Attachments = detail.Attachments.Select(a => new FeedbackAttachmentView(a)).ToList();
var text = detail.Summary.Title + "\n" + string.Join("\n", detail.Fields.Values);
Links = FeedbackLinks.Find(text, out var total).Select(FeedbackLinks.Defang).ToList();
LinkCount = total;
}
// ------------------------------------------------------------------ Liens (avertissement)
/// <summary>Liens et adresses trouvés, sous forme désamorcée (« hxxps[://]exemple[.]com »).</summary>
public IReadOnlyList<string> Links { get; }
public int LinkCount { get; }
public bool HasLinks => LinkCount > 0;
public string LinksWarning => LinkCount == 0 ? "" :
$"Cette demande contient {(LinkCount == 1 ? "un lien ou une adresse" : $"{LinkCount} liens ou adresses")}. Elle vient d'un " +
"inconnu : n'ouvrez pas ces liens et ne les copiez pas dans un navigateur, ils peuvent mener à un site d'hameçonnage ou " +
"à un fichier malveillant. En cas de doute, supprimez la demande.";
public string LinksText => string.Join("\n", Links.Select(l => "• " + l)) +
(LinkCount > Links.Count ? $"\n… et {LinkCount - Links.Count} autre(s)" : "");
// ------------------------------------------------------------------ Captures
public IReadOnlyList<FeedbackAttachmentView> Attachments { get; }
public bool HasAttachments => Attachments.Count > 0;
private static readonly IReadOnlyList<FeedbackStatusChoice> AllStatuses =
FeedbackStatuses.All.Select(s => new FeedbackStatusChoice(s, FeedbackStatuses.Label(s))).ToList();
@@ -137,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)}";
@@ -194,16 +321,21 @@ 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));
lines.Add(new FeedbackFieldLine(field.Label, field.DisplayValue(value), field.Kind == FeedbackFieldKind.Multiline,
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')));
lines.Add(new FeedbackFieldLine(id, value, value.Length > 60 || value.Contains('\n'), FeedbackLinks.Contains(value)));
return lines;
}
}
@@ -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
@@ -308,9 +348,28 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
var detail = await Context.Session.GetFeedbackAsync(gameId, item.Id);
if (!ReferenceEquals(item, _selectedItem)) return; // autre demande choisie entre-temps
item.Summary = detail.Summary;
Detail = NewDetail(detail);
var view = NewDetail(detail);
Detail = view;
await LoadAttachmentsAsync(gameId, view);
});
/// <summary>Captures de la fiche affichée, une par une (chacune revérifiée avant l'affichage).</summary>
private async Task LoadAttachmentsAsync(string gameId, FeedbackDetailView view)
{
foreach (var attachment in view.Attachments)
{
if (!ReferenceEquals(view, _detail)) return; // autre demande affichée entre-temps
try
{
attachment.Load(await Context.Session.GetFeedbackAttachmentAsync(gameId, view.Id, attachment.Info.Index));
}
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
{
attachment.Fail(ApiErrorText.Describe(ex));
}
}
}
private FeedbackDetailView NewDetail(FeedbackDetail detail) =>
new(detail, CanManage, () => SaveCommand.RaiseCanExecuteChanged(), reference => Context.Dialogs.CopyToClipboard(reference));
@@ -335,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();
@@ -366,6 +425,7 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
var saved = await Context.Session.UpdateFeedbackAsync(gameId, detail.Id, detail.Revision, detail.Status.Value, detail.NoteToSend);
if (_loaded.FirstOrDefault(i => i.Id == saved.Summary.Id) is { } item) item.Summary = saved.Summary;
Detail = NewDetail(saved);
await LoadAttachmentsAsync(gameId, _detail!);
StatusMessage = $"Demande {saved.Summary.Reference} enregistrée : {FeedbackStatuses.Label(saved.Summary.Status)}.";
await RefreshCountsAsync();
}
@@ -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}";
});
}
@@ -0,0 +1,46 @@
using GameLauncher.Contracts.Feedback;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Feedback;
/// <summary>Image préparée par le launcher (convertie en PNG ou JPEG, réduite si besoin), ou raison du refus.</summary>
/// <param name="Name">Nom affiché au joueur (jamais envoyé).</param>
public sealed record ScreenshotResult(byte[]? Bytes, string Name, string? Error)
{
public static ScreenshotResult Refused(string name, string error) => new(null, name, error);
}
/// <summary>
/// Source des captures d'écran (fenêtre WPF : explorateur Windows, presse-papiers). Elle décode l'image choisie (PNG,
/// JPEG, BMP, GIF, WebP reconnus à leur contenu), la réduit si besoin et la réencode en PNG ou JPEG SANS ses
/// métadonnées ; le module la revérifie ensuite avec <see cref="FeedbackImages.Sanitize"/>.
/// </summary>
public interface IScreenshotSource
{
/// <summary>Explorateur Windows (null : annulé).</summary>
ScreenshotResult? PickFile();
/// <summary>Image du presse-papiers (Win+Maj+S, Impr. écran) ; null : aucune image.</summary>
ScreenshotResult? FromClipboard();
}
/// <summary>Capture jointe à la demande (déjà contrôlée et nettoyée).</summary>
public sealed class FeedbackAttachmentDraft
{
public FeedbackAttachmentDraft(string name, byte[] content, FeedbackImages.ImageInfo info, Action<FeedbackAttachmentDraft> remove)
{
Name = name;
Content = content;
Info = info;
RemoveCommand = new RelayCommand(() => remove(this));
}
public string Name { get; }
public byte[] Content { get; }
public FeedbackImages.ImageInfo Info { get; }
/// <summary>« 1920 × 1080 · PNG · 845 Ko ».</summary>
public string Details => $"{Info.Width} × {Info.Height} · {Info.Format.ToUpperInvariant()} · {Math.Max(1, Content.Length / 1024)} Ko";
public RelayCommand RemoveCommand { get; }
}
@@ -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
};
@@ -11,10 +11,13 @@ namespace GameLauncher.Modules.Feedback;
/// <param name="InstalledVersion">Version installée (null : jeu non installé).</param>
public sealed record FeedbackTarget(string GameId, string GameName, string? InstalledVersion, string LauncherVersion);
/// <summary>Envoi d'une demande (Launcher API en production, simulé dans les tests). Renvoie la référence.</summary>
/// <summary>Résultat d'un envoi : référence, captures reçues, raison de l'échec d'une capture.</summary>
public sealed record FeedbackSent(string Reference, int AttachmentsSent, string? AttachmentError);
/// <summary>Envoi d'une demande et de ses captures (Launcher API en production, simulé dans les tests).</summary>
public interface IFeedbackSender
{
Task<string> SendAsync(FeedbackSubmission submission, CancellationToken ct);
Task<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, CancellationToken ct);
}
/// <summary>Envoi par la Launcher API (route anonyme : défi anti-spam résolu sur ce PC, aucune session).</summary>
@@ -24,8 +27,11 @@ public sealed class ApiFeedbackSender : IFeedbackSender
public ApiFeedbackSender(LauncherApiClient client) => _client = client;
public async Task<string> SendAsync(FeedbackSubmission submission, CancellationToken ct) =>
(await _client.SendFeedbackAsync(submission, ct).ConfigureAwait(false)).Reference;
public async Task<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, CancellationToken ct)
{
var result = await _client.SendFeedbackAsync(submission, attachments, ct).ConfigureAwait(false);
return new FeedbackSent(result.Submitted.Reference, result.AttachmentsSent, result.AttachmentError);
}
}
/// <summary>Type de demande proposé à la première étape (une tuile par formulaire).</summary>
@@ -34,6 +40,7 @@ public sealed class FeedbackKindChoice
public FeedbackKindChoice(FeedbackForm form, Action<FeedbackForm> select)
{
Form = form;
CustomIcon = form.CustomIconBytes();
SelectCommand = new RelayCommand(() => select(form));
}
@@ -41,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; }
}
@@ -53,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.
@@ -62,6 +76,12 @@ public enum FeedbackStep
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;
private CancellationTokenSource? _sending;
private FeedbackStep _step = FeedbackStep.ChooseKind;
@@ -73,15 +93,98 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
private string? _errorMessage;
private string? _reference;
public FeedbackFormViewModel(FeedbackTarget target, IFeedbackSender sender, FeedbackTechInfo? tech = null)
/// <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, 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 += (_, _) =>
{
OnPropertyChanged(nameof(HasAttachments));
OnPropertyChanged(nameof(AttachmentsText));
OnPropertyChanged(nameof(CanAddScreenshot));
AddFileCommand.RaiseCanExecuteChanged();
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; }
@@ -89,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;
@@ -121,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)
@@ -200,6 +313,87 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
"Demande anonyme : aucun compte, aucune adresse IP enregistrée avec votre demande. N'écrivez pas d'informations " +
"personnelles (e-mail, nom, téléphone…). Nous lisons toutes les demandes, mais ne pouvons pas y répondre.";
// ------------------------------------------------------------------ Captures d'écran
public ObservableCollection<FeedbackAttachmentDraft> Attachments { get; } = new();
/// <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 => 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";
public string ScreenshotPrivacyText =>
"Les captures sont converties et débarrassées de leurs métadonnées (position, appareil, date) avant l'envoi. " +
"Vérifiez qu'elles ne montrent pas d'informations personnelles (pseudo, e-mail, fenêtres ouvertes…).";
public RelayCommand AddFileCommand { get; }
public RelayCommand PasteCommand { get; }
/// <summary>Image refusée (pas une image, format non pris en charge, trop grande…).</summary>
public string? AttachmentError
{
get => _attachmentError;
private set
{
if (SetProperty(ref _attachmentError, value)) OnPropertyChanged(nameof(HasAttachmentError));
}
}
public bool HasAttachmentError => !string.IsNullOrEmpty(_attachmentError);
/// <summary>Après l'envoi : captures non reçues par le serveur (la demande, elle, est enregistrée).</summary>
public string? AttachmentWarning
{
get => _attachmentWarning;
private set
{
if (SetProperty(ref _attachmentWarning, value)) OnPropertyChanged(nameof(HasAttachmentWarning));
}
}
public bool HasAttachmentWarning => !string.IsNullOrEmpty(_attachmentWarning);
/// <summary>
/// Ajoute une image préparée par la source, après le contrôle commun (vraie image PNG ou JPEG, dimensions, taille,
/// structure) et le retrait des métadonnées.
/// </summary>
public void AddScreenshot(ScreenshotResult? result, bool fromClipboard = false)
{
if (result is null)
{
if (fromClipboard) AttachmentError = "Le presse-papiers ne contient pas d'image.";
return;
}
if (!CanAddScreenshot)
{
AttachmentError = $"{FeedbackImages.MaxAttachments} captures au plus.";
return;
}
if (result.Error is not null || result.Bytes is null)
{
AttachmentError = $"{result.Name} : {result.Error ?? "image illisible."}";
return;
}
var error = FeedbackImages.Sanitize(result.Bytes, out var info, out var clean);
if (error is not null)
{
AttachmentError = $"{result.Name} : {error}";
return;
}
AttachmentError = null;
Attachments.Add(new FeedbackAttachmentDraft(result.Name, clean!, info!, draft =>
{
if (!_isSending) Attachments.Remove(draft);
}));
}
// ------------------------------------------------------------------ Envoi
public AsyncRelayCommand SendCommand { get; }
@@ -213,6 +407,9 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
{
if (!SetProperty(ref _isSending, value)) return;
OnPropertyChanged(nameof(IsEditable));
OnPropertyChanged(nameof(CanAddScreenshot));
AddFileCommand.RaiseCanExecuteChanged();
PasteCommand.RaiseCanExecuteChanged();
RaiseCommands();
}
}
@@ -263,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;
}
@@ -288,14 +486,27 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
_sending = new CancellationTokenSource();
try
{
Reference = await _sender.SendAsync(submission, _sending.Token);
Log.Info($"[{Target.GameId}] Demande envoyée ({_form.Kind}) : {Reference}");
// 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" +
(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)");
Step = FeedbackStep.Done;
}
catch (OperationCanceledException) when (_sending.IsCancellationRequested)
{
// 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}");
@@ -323,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 " +
+2
View File
@@ -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>
+98 -6
View File
@@ -7,6 +7,7 @@ using System.Windows.Media;
using System.Windows.Threading;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Core.Catalog;
using GameLauncher.Core.Maintenance;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Services;
@@ -20,6 +21,7 @@ using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Files;
using GameLauncher.Modules.Admin.Maintenance;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Platform;
@@ -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
@@ -814,7 +899,8 @@ public partial class App : Application, ILauncherRestartHost
/// <summary>
/// Nouvelle version non installée automatiquement (option désactivée, échec précédent,
/// dossier protégé) : simple information dans le bandeau.
/// dossier protégé, « Plus tard ») : simple information dans le bandeau, en avertissement pour une
/// version prioritaire.
/// </summary>
private async Task NotifyLauncherUpdateAsync()
{
@@ -822,7 +908,13 @@ public partial class App : Application, ILauncherRestartHost
{
await Task.Delay(TimeSpan.FromSeconds(4));
var check = _updates!.SkippedUpdate ?? await _updates.CheckAsync();
if (check.IsAvailable)
if (check.IsPriority)
// Correctif critique non installé au démarrage (« Plus tard », échec précédent, dossier protégé…)
_launcher?.SelectedGame?.ShowNotice("Mise à jour importante du launcher",
$"La version {check.AvailableDisplay} corrige un problème important et n'est pas encore installée : " +
"Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
DialogKind.Warning);
else if (check.IsAvailable)
_launcher?.SelectedGame?.ShowNotice("Nouvelle version du launcher",
$"La version {check.AvailableDisplay} est disponible : Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
DialogKind.Info);
+1 -1
View File
@@ -47,7 +47,7 @@ public static class ScrollAssist
}
/// <summary>
/// Molette de la souris dans le menu d'administration : elle fait défiler ce qui est SOUS le curseur, jamais une
/// Molette de la souris dans le menu d'administration et la fenêtre « Aide » : elle fait défiler ce qui est SOUS le curseur, jamais une
/// liste déroulante fermée (pas de paramètre changé par mégarde en faisant défiler la page), ni un élément qui
/// garderait la molette sans pouvoir défiler (liste, champ texte) : la page défile alors à sa place.
/// Une liste déroulante OUVERTE garde son comportement normal (défilement de ses choix).
+1 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>3.0.0</Version>
<Version>3.4.1</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -128,12 +128,15 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
/// <summary>
/// Au démarrage, avant l'affichage de la fenêtre principale : recherche rapide et, si une
/// nouvelle version existe et que la mise à jour automatique est activée, installation.
/// Mise à jour automatique désactivée : la recherche a lieu quand même, et seule une version
/// PRIORITAIRE (correctif critique, information signée) est installée. Le réglage du joueur
/// (self-update.json) n'est jamais modifié : il reste désactivé pour les versions suivantes.
/// Renvoie vrai si le launcher redémarre (l'appelant doit alors s'arrêter là).
/// </summary>
public async Task<bool> RunStartupCheckAsync()
{
ExecutableSwapper.CleanupLeftovers(TargetPath, CurrentVersionValue);
if (_client is null || !AutoUpdate || !IsSingleFileBuild) return false;
if (_client is null || !IsSingleFileBuild) return false;
LauncherUpdateCheck check;
try
@@ -148,6 +151,18 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
}
if (!check.IsAvailable) return false;
if (!AutoUpdate && !check.IsPriority)
{
// Option désactivée, version normale : simple information dans le bandeau (sans seconde recherche).
SkippedUpdate = check;
return false;
}
if (check.IsPriority)
Log.Warn($"Launcher {check.AvailableDisplay} : mise à jour PRIORITAIRE (correctif publié en {check.Release!.PriorityVersion})" +
(AutoUpdate ? "." : ", installée bien que la mise à jour automatique soit désactivée (réglage conservé)."));
// Échec précédent : même prioritaire, pas de nouvelle tentative automatique (une version qui ne démarre pas
// sur ce PC imposerait 60 s d'attente et un retour arrière à chaque lancement). Bandeau + bouton manuel.
if (string.Equals(_settings.FailedVersion, check.AvailableDisplay, StringComparison.OrdinalIgnoreCase))
{
Log.Warn($"Launcher {check.AvailableDisplay} : installation automatique ignorée (échec précédent). " +
@@ -167,7 +182,10 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
SkippedUpdate = check;
return false;
}
return await InstallAsync(check, automatic: true);
if (await InstallAsync(check, automatic: true)) return true;
// Version prioritaire non installée (« Plus tard », erreur) : rappel dans le bandeau une fois la fenêtre affichée.
if (check.IsPriority) SkippedUpdate = check;
return false;
}
// ------------------------------------------------------------------ Installation
@@ -265,10 +283,17 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
Log.Error("Mise à jour du launcher impossible", ex);
// Au démarrage, une erreur persistante (fichier absent du serveur, antivirus…) ne doit
// pas réapparaître à chaque lancement : cette version ne sera plus installée automatiquement.
if (automatic) RememberFailure(update);
vm.SetError(FriendlyMessage(ex) + (automatic
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
: ""));
// Exception : une version PRIORITAIRE (correctif critique) est retentée au prochain démarrage
// (erreur de téléchargement le plus souvent passagère). Seul un échec de démarrage de la nouvelle
// version (plus haut, après retour arrière) l'écarte des installations automatiques.
var retryAtNextStart = automatic && update.IsPriority;
if (automatic && !retryAtNextStart) RememberFailure(update);
vm.SetError(FriendlyMessage(ex) + (retryAtNextStart
? "\n\nCette mise à jour est importante : elle sera retentée au prochain démarrage du launcher " +
"(ou dès maintenant depuis Paramètres du launcher › Vérifier les mises à jour)."
: automatic
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
: ""));
}
// Erreur : la fenêtre reste ouverte jusqu'à ce que l'utilisateur la ferme.
@@ -28,6 +28,7 @@ public sealed class AdminWindowService : IDisposable
private readonly Uri? _apiUrl;
private readonly string? _apiUrlError;
private readonly PlayerSigningCheck? _playerSigning;
private readonly Action? _maintenanceChanged;
private LauncherApiClient? _client;
private AdminWindow? _window;
@@ -39,9 +40,10 @@ public sealed class AdminWindowService : IDisposable
/// <param name="playerSigning">
/// Contrôle du trousseau des joueurs avant une publication signée (null : signatures non vérifiées, pas d'avertissement).
/// </param>
/// <param name="maintenanceChanged">Après une publication du mode maintenance : relecture immédiate par ce launcher.</param>
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes,
PlayerSigningCheck? playerSigning = null)
PlayerSigningCheck? playerSigning = null, Action? maintenanceChanged = null)
{
_owner = owner;
_modules = modules;
@@ -52,6 +54,7 @@ public sealed class AdminWindowService : IDisposable
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
_apiUrlError = apiUrlError;
_playerSigning = playerSigning;
_maintenanceChanged = maintenanceChanged;
}
public bool IsAvailable => _apiUrl is not null;
@@ -93,7 +96,8 @@ public sealed class AdminWindowService : IDisposable
SettingsPath = _settingsPath,
LauncherVersion = _launcherVersion,
RootKeys = LauncherUpdateService.LoadRootKeys(),
PlayerSigning = _playerSigning
PlayerSigning = _playerSigning,
MaintenanceChanged = _maintenanceChanged
});
window = new AdminWindow(shell);
if (_owner.IsVisible) window.Owner = _owner;
@@ -10,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));
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;
@@ -0,0 +1,109 @@
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Feedback;
using Microsoft.Win32;
namespace GameLauncher.Services;
/// <summary>
/// Captures d'écran du bouton « Aide » : image choisie dans l'explorateur Windows ou collée depuis le presse-papiers.
/// • format reconnu au CONTENU (PNG, JPEG, BMP, GIF, WebP), jamais à l'extension ; fichier de 30 Mo au plus ;
/// • dimensions lues avant tout décodage complet (image démesurée refusée sans être chargée en mémoire) ;
/// • image décodée par Windows (preuve que c'est réellement une image), réduite à 3 840 px au plus, puis réencodée en
/// PNG (JPEG pour une photo JPEG ou si le PNG est trop lourd) SANS métadonnées : ni position GPS, ni appareil, ni date ;
/// • le nom du fichier n'est jamais envoyé (affiché au joueur seulement).
/// </summary>
public sealed class ScreenshotSource : IScreenshotSource
{
private const long MaxInputBytes = 30L * 1024 * 1024;
private const long MaxInputPixels = 120_000_000; // ≈ 12 000 × 10 000 : au-delà, refus avant décodage
private readonly Func<Window?> _owner;
public ScreenshotSource(Func<Window?> owner) => _owner = owner;
public ScreenshotResult? PickFile()
{
var dialog = new OpenFileDialog
{
Title = "Ajouter une capture d'écran",
Filter = "Images (PNG, JPEG, BMP, GIF, WebP)|" + string.Join(";", FeedbackImages.InputExtensions.Select(e => "*" + e)),
CheckFileExists = true,
Multiselect = false,
InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures)
};
if (dialog.ShowDialog(_owner()) != true) return null;
var name = Path.GetFileName(dialog.FileName);
try
{
var info = new FileInfo(dialog.FileName);
if (info.Length > MaxInputBytes) return ScreenshotResult.Refused(name, "fichier trop volumineux (30 Mo au plus).");
var bytes = File.ReadAllBytes(dialog.FileName);
var format = FeedbackImages.DetectInputFormat(bytes);
if (format is null) return ScreenshotResult.Refused(name, "ce fichier n'est pas une image PNG, JPEG, BMP, GIF ou WebP.");
using var stream = new MemoryStream(bytes, writable: false);
// Décodage différé (BitmapCacheOption.Default) : les dimensions sont lues avant les pixels.
var decoder = BitmapDecoder.Create(stream, BitmapCreateOptions.IgnoreColorProfile, BitmapCacheOption.Default);
if (decoder.Frames.Count == 0) return ScreenshotResult.Refused(name, "image vide.");
return Encode(name, decoder.Frames[0], preferJpeg: format == FeedbackImages.Jpeg);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or
FileFormatException or ArgumentException or InvalidOperationException or OverflowException or
System.Runtime.InteropServices.COMException)
{
Log.Warn($"Capture refusée ({name}) : {ex.Message}");
return ScreenshotResult.Refused(name, "image illisible ou format non pris en charge par Windows.");
}
}
public ScreenshotResult? FromClipboard()
{
try
{
if (!Clipboard.ContainsImage()) return null;
var image = Clipboard.GetImage();
return image is null ? null : Encode("Image collée", image, preferJpeg: false);
}
catch (Exception ex) when (ex is System.Runtime.InteropServices.COMException or System.Runtime.InteropServices.ExternalException or
NotSupportedException or ArgumentException or InvalidOperationException)
{
Log.Warn($"Capture du presse-papiers refusée : {ex.Message}");
return ScreenshotResult.Refused("Image collée", "image du presse-papiers illisible.");
}
}
/// <summary>Réduction éventuelle puis réencodage sans métadonnées, en restant sous la taille maximale.</summary>
private static ScreenshotResult Encode(string name, BitmapSource source, bool preferJpeg)
{
if (source.PixelWidth <= 0 || source.PixelHeight <= 0 || (long)source.PixelWidth * source.PixelHeight > MaxInputPixels)
return ScreenshotResult.Refused(name, "dimensions de l'image refusées.");
// Côté le plus long ramené à 3 840 px ET surface ramenée sous la limite du serveur (ex. capture carrée 4K).
var pixels = (double)source.PixelWidth * source.PixelHeight;
var scale = Math.Min(1.0, Math.Min((double)FeedbackImages.TargetMaxSide / Math.Max(source.PixelWidth, source.PixelHeight),
Math.Sqrt(FeedbackImages.MaxPixels * 0.98 / pixels)));
for (var attempt = 0; attempt < 5; attempt++)
{
BitmapSource frame = scale < 1.0 ? new TransformedBitmap(source, new ScaleTransform(scale, scale)) : source;
if (frame.Format != PixelFormats.Bgra32 && frame.Format != PixelFormats.Bgr32)
frame = new FormatConvertedBitmap(frame, PixelFormats.Bgra32, null, 0);
frame.Freeze();
foreach (var jpeg in preferJpeg ? new[] { true } : new[] { false, true })
{
BitmapEncoder encoder = jpeg ? new JpegBitmapEncoder { QualityLevel = 88 } : new PngBitmapEncoder();
// BitmapFrame.Create(source) : nouvelle image, AUCUNE métadonnée de l'original n'est reprise.
encoder.Frames.Add(BitmapFrame.Create(jpeg ? new FormatConvertedBitmap(frame, PixelFormats.Bgr24, null, 0) : frame));
using var output = new MemoryStream();
encoder.Save(output);
if (output.Length <= FeedbackImages.MaxBytes) return new ScreenshotResult(output.ToArray(), name, null);
}
scale *= 0.75; // trop lourde même en JPEG : réduite
}
return ScreenshotResult.Refused(name, "image trop lourde, même réduite.");
}
}
+684 -10
View File
@@ -10,6 +10,7 @@
xmlns:asig="clr-namespace:GameLauncher.Modules.Admin.Signing;assembly=GameLauncher.Modules.Admin"
xmlns:afil="clr-namespace:GameLauncher.Modules.Admin.Files;assembly=GameLauncher.Modules.Admin"
xmlns:afb="clr-namespace:GameLauncher.Modules.Admin.Feedback;assembly=GameLauncher.Modules.Admin"
xmlns:amnt="clr-namespace:GameLauncher.Modules.Admin.Maintenance;assembly=GameLauncher.Modules.Admin"
xmlns:conv="clr-namespace:GameLauncher.Converters">
<!--
@@ -367,6 +368,19 @@
</DockPanel>
</Border>
<!-- Mode maintenance en cours (tous les administrateurs le voient ; la page « Maintenance » exige maintenance.manage) -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}" Background="#26FF8A1F"
Visibility="{Binding IsMaintenanceActive, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Gérer" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.TrafficCone}" Command="{Binding OpenMaintenanceCommand}"
Visibility="{Binding CanOpenMaintenance, Converter={StaticResource BoolToVisibility}}" />
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.TrafficCone}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding MaintenanceText}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center" />
</DockPanel>
</Border>
<!-- Compte connecté -->
<Border Style="{StaticResource AdminCardStyle}">
<Grid>
@@ -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"
@@ -2134,6 +2154,14 @@
</StackPanel>
<StackPanel Grid.Column="2" VerticalAlignment="Center" HorizontalAlignment="Right">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Path Width="12" Height="12" Stretch="Uniform" Margin="0,0,6,0" VerticalAlignment="Center"
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
ToolTip="Contient un lien ou une adresse : prudence"
Visibility="{Binding HasLinks, Converter={StaticResource BoolToVisibility}}" />
<Path Width="12" Height="12" Stretch="Uniform" Margin="0,0,6,0" VerticalAlignment="Center"
Fill="{DynamicResource TextSecondaryBrush}" Data="{StaticResource Icon.ImageOutline}"
ToolTip="{Binding AttachmentsToolTip}"
Visibility="{Binding HasAttachments, Converter={StaticResource BoolToVisibility}}" />
<Ellipse x:Name="NewDot" Width="8" Height="8" Margin="0,0,6,0" VerticalAlignment="Center"
Fill="{DynamicResource WarningBrush}" Visibility="Collapsed" ToolTip="Nouvelle" />
<TextBlock x:Name="StatusText" Text="{Binding StatusLabel}" FontSize="11" FontWeight="SemiBold"
@@ -2170,11 +2198,31 @@
<!-- Fiche d'une demande -->
<DataTemplate DataType="{x:Type afb:FeedbackDetailView}">
<StackPanel>
<!-- Liens ou adresses dans la demande (envoyée par un inconnu) : avertissement, liens désamorcés -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasLinks, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="18" Height="18" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,0,0"
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}" />
<StackPanel Margin="10,0,0,0">
<TextBlock Text="{Binding LinksWarning}" TextWrapping="Wrap" FontSize="12.5" FontWeight="SemiBold" />
<TextBlock Text="{Binding LinksText}" TextWrapping="Wrap" FontSize="12" Margin="0,6,0,0"
FontFamily="Cascadia Mono, Consolas, Courier New" Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
</DockPanel>
</Border>
<Border Style="{StaticResource AdminCardStyle}">
<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}"
@@ -2196,16 +2244,69 @@
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,0,0,12">
<TextBlock Text="{Binding Label}" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,0,4" />
<TextBox Text="{Binding Value, Mode=OneWay}" IsReadOnly="True" TextWrapping="Wrap" BorderThickness="0"
Background="Transparent" Padding="0" FontSize="13" Foreground="{DynamicResource TextPrimaryBrush}"
CaretBrush="{DynamicResource TextPrimaryBrush}" SelectionBrush="{DynamicResource AccentBrush}"
IsUndoEnabled="False" />
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{Binding Label}" Style="{StaticResource AdminFieldLabelStyle}" Margin="0" />
<Path Width="11" Height="11" Stretch="Uniform" Margin="6,0,0,0" VerticalAlignment="Center"
Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}" ToolTip="Contient un lien ou une adresse"
Visibility="{Binding HasLink, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<Border x:Name="ValueBorder" BorderThickness="0" CornerRadius="8" Padding="0">
<TextBox Text="{Binding Value, Mode=OneWay}" IsReadOnly="True" TextWrapping="Wrap" BorderThickness="0"
Background="Transparent" Padding="0" FontSize="13" Foreground="{DynamicResource TextPrimaryBrush}"
CaretBrush="{DynamicResource TextPrimaryBrush}" SelectionBrush="{DynamicResource AccentBrush}"
IsUndoEnabled="False" />
</Border>
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding HasLink}" Value="True">
<Setter TargetName="ValueBorder" Property="BorderBrush" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="ValueBorder" Property="BorderThickness" Value="1" />
<Setter TargetName="ValueBorder" Property="Padding" Value="8,6" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Captures d'écran : revérifiées avant l'affichage, aperçu réduit, « Agrandir » -->
<StackPanel Visibility="{Binding HasAttachments, Converter={StaticResource BoolToVisibility}}" Margin="0,0,0,12">
<TextBlock Text="Captures d'écran" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,0,6" />
<ItemsControl ItemsSource="{Binding Attachments}" Focusable="False">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="0,0,0,10" CornerRadius="12" Padding="8" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<StackPanel>
<DockPanel Margin="2,0,0,6">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="{Binding ToggleText}"
ui:IconAssist.Icon="{StaticResource Icon.ArrowExpand}" Command="{Binding ToggleCommand}" />
<TextBlock Text="{Binding Label}" FontSize="12" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextSecondaryBrush}" />
</DockPanel>
<TextBlock Text="Chargement…" Style="{StaticResource AdminHintStyle}"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding Error}" FontSize="12" TextWrapping="Wrap" Foreground="{DynamicResource DangerBrush}"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}" />
<Border x:Name="Frame" CornerRadius="8" ClipToBounds="True" MaxHeight="180" HorizontalAlignment="Left"
Background="{DynamicResource SurfaceAltBrush}"
Visibility="{Binding HasContent, Converter={StaticResource BoolToVisibility}}">
<Image x:Name="Picture" Stretch="Uniform" StretchDirection="DownOnly"
Source="{Binding Content, Converter={StaticResource AdminBytesToImage}, ConverterParameter=480}" />
</Border>
</StackPanel>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsExpanded}" Value="True">
<Setter TargetName="Frame" Property="MaxHeight" Value="900" />
<Setter TargetName="Picture" Property="Source"
Value="{Binding Content, Converter={StaticResource AdminBytesToImage}, ConverterParameter=1600}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBlock Text="Informations techniques" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,4,0,4" />
<TextBlock Text="{Binding TechText}" FontSize="12" TextWrapping="Wrap" FontFamily="Cascadia Mono, Consolas, Courier New"
Foreground="{DynamicResource TextSecondaryBrush}" />
@@ -2354,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é -->
@@ -2476,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">
@@ -2499,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}">
@@ -2811,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}" />
+167
View File
@@ -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>
+2
View File
@@ -91,6 +91,8 @@
<!-- Publication d'une version (phase 5) -->
<Geometry x:Key="Icon.CloudUpload">M14,13V17H10V13H7L12,8L17,13M19.35,10.03C18.67,6.59 15.64,4 12,4C9.11,4 6.6,5.64 5.35,8.03C2.34,8.36 0,10.9 0,14A6,6 0 0,0 6,20H19A5,5 0 0,0 24,15C24,12.36 21.95,10.22 19.35,10.03Z</Geometry>
<!-- Cône de chantier (mode maintenance) : bandes du cône et socle -->
<Geometry x:Key="Icon.TrafficCone">M9.6,2H14.4L15.2,5H8.8L9.6,2M8.3,7H15.7L16.7,11H7.3L8.3,7M6.8,13H17.2L18.2,17H5.8L6.8,13M3,19H21V22H3V19Z</Geometry>
<Geometry x:Key="Icon.Pause">M14,19H18V5H14M6,19H10V5H6V19Z</Geometry>
<Geometry x:Key="Icon.PackageVariant">M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.04,13.36L20.03,9.27L19.5,8.35L12.51,12.45L13.04,13.36Z</Geometry>
<Geometry x:Key="Icon.Laptop">M4,6H20V16H4M20,18A2,2 0 0,0 22,16V6C22,4.89 21.1,4 20,4H4C2.89,4 2,4.89 2,6V16A2,2 0 0,0 4,18H0V20H24V18H20Z</Geometry>
+72
View File
@@ -0,0 +1,72 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
Mode maintenance (publié depuis le menu d'administration) : palette rouge / orange appliquée par-dessus le thème du
jeu, dans la fenêtre principale seulement (MainWindow l'ajoute à ses ressources pendant la maintenance, puis la
retire). Mêmes clés que Themes/Default.xaml : les vues, en {DynamicResource …}, changent de couleur à chaud.
Les clés « Maintenance* » ne servent qu'à la carte affichée au centre de la fenêtre.
-->
<!-- ============================================================ Palette -->
<Color x:Key="AccentColor">#FF3B30</Color>
<Color x:Key="Accent2Color">#FF9F0A</Color>
<Color x:Key="BackgroundColor">#140806</Color>
<SolidColorBrush x:Key="BackgroundBrush" Color="#140806" />
<SolidColorBrush x:Key="AccentBrush" Color="#FF453A" />
<SolidColorBrush x:Key="Accent2Brush" Color="#FF9F0A" />
<SolidColorBrush x:Key="OnAccentBrush" Color="White" />
<LinearGradientBrush x:Key="AccentGradientBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FF3B30" Offset="0" />
<GradientStop Color="#FF9500" Offset="1" />
</LinearGradientBrush>
<SolidColorBrush x:Key="BorderBrush" Color="#2EFF7A45" />
<SolidColorBrush x:Key="BorderStrongBrush" Color="#4DFF7A45" />
<SolidColorBrush x:Key="ChipBrush" Color="#991C0C08" />
<SolidColorBrush x:Key="ChipIconBoxBrush" Color="#33FF6A2B" />
<SolidColorBrush x:Key="BadgeBrush" Color="#24FF6A2B" />
<SolidColorBrush x:Key="MenuBrush" Color="#F21C0E0A" />
<SolidColorBrush x:Key="ActionBarBrush" Color="#EB170B08" />
<SolidColorBrush x:Key="WindowBorderBrush" Color="#66FF6A2B" />
<SolidColorBrush x:Key="RailBrush" Color="#CC140806" />
<!-- ============================================================ Fond : illustration du jeu fortement teintée -->
<LinearGradientBrush x:Key="HeroOverlayBrush" StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#F2140806" Offset="0" />
<GradientStop Color="#D9140806" Offset="0.5" />
<GradientStop Color="#C7140806" Offset="1" />
</LinearGradientBrush>
<LinearGradientBrush x:Key="VignetteBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#B3140806" Offset="0" />
<GradientStop Color="#00140806" Offset="0.2" />
<GradientStop Color="#00140806" Offset="0.6" />
<GradientStop Color="#F2140806" Offset="1" />
</LinearGradientBrush>
<!-- Lueur rouge / orange derrière la carte de maintenance -->
<RadialGradientBrush x:Key="AmbientGlowBrush" Center="0.55,0.45" GradientOrigin="0.55,0.45" RadiusX="0.7" RadiusY="0.8">
<GradientStop Color="#66FF3B1F" Offset="0" />
<GradientStop Color="#33FF8A00" Offset="0.45" />
<GradientStop Color="#00FF8A00" Offset="1" />
</RadialGradientBrush>
<!-- ============================================================ Carte « Maintenance en cours » -->
<SolidColorBrush x:Key="MaintenanceCardBrush" Color="#E61A0D09" />
<SolidColorBrush x:Key="MaintenanceCardBorderBrush" Color="#80FF6A2B" />
<!-- Bandes de chantier (diagonales orange / brun), répétées -->
<DrawingBrush x:Key="MaintenanceStripesBrush" TileMode="Tile" Viewport="0,0,16,16" ViewportUnits="Absolute"
Viewbox="0,0,16,16" ViewboxUnits="Absolute" Stretch="None">
<DrawingBrush.Drawing>
<DrawingGroup>
<GeometryDrawing Brush="#FF2A130B" Geometry="M0,0H16V16H0Z" />
<GeometryDrawing Brush="#FFFF8A1F" Geometry="M0,0L8,0L0,8Z M16,0L16,8L8,16L0,16Z" />
</DrawingGroup>
</DrawingBrush.Drawing>
</DrawingBrush>
</ResourceDictionary>
+108 -11
View File
@@ -4,6 +4,7 @@ using System.IO;
using System.Net;
using System.Net.Http;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Core.Models;
using GameLauncher.Core.Services;
using GameLauncher.Core.Trust;
@@ -65,6 +66,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private string? _lastInstallDirectory;
private int _bannerVersion;
private bool _launchWhenReady;
private bool _isMaintenance;
public GameViewModel(LauncherConfig config, LauncherSettings settings, string settingsPath,
LauncherPreferences preferences, GameInstaller installer, ManifestClient manifestClient,
@@ -88,8 +90,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
LauncherVersion = launcherVersion;
ApplySettings();
PrimaryCommand = Command(ExecutePrimary, CanExecutePrimary);
CancelCommand = Command(Cancel, () => State == LauncherState.Busy && _operationCancellable);
// Mode maintenance : toutes les commandes sont désactivées, sauf la désinstallation, l'annulation d'une opération
// déjà commencée et la fermeture du bandeau (Command(…, allowedInMaintenance: true)).
PrimaryCommand = Command(ExecutePrimary, CanExecutePrimary, allowedInMaintenance: true);
CancelCommand = Command(Cancel, () => State == LauncherState.Busy && _operationCancellable, allowedInMaintenance: true);
CheckUpdatesCommand = Command(() => Fire(() => CheckForUpdatesAsync(userInitiated: true)),
() => State is LauncherState.NotInstalled or LauncherState.Ready or LauncherState.UpdateAvailable
or LauncherState.NeedsRepair or LauncherState.Broken);
@@ -101,12 +105,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
() => State is LauncherState.NotInstalled or LauncherState.Broken);
ForgetCommand = Command(Forget, () => State == LauncherState.Broken);
UninstallCommand = Command(() => Fire(async () => await UninstallAsync()),
() => State is LauncherState.Ready or LauncherState.UpdateAvailable or LauncherState.NeedsRepair or LauncherState.Broken);
() => State is LauncherState.Ready or LauncherState.UpdateAvailable or LauncherState.NeedsRepair or LauncherState.Broken,
allowedInMaintenance: true);
OpenFolderCommand = Command(() => { if (InstallPath is not null) _window.OpenFolder(InstallPath); },
() => InstallPath is not null && State != LauncherState.Broken && Directory.Exists(InstallPath));
OpenSettingsCommand = Command(OpenSettings);
OpenWebsiteCommand = Command(() => { if (HasWebsite) _window.OpenUrl(_config.WebsiteUrl!); }, () => HasWebsite);
DismissBannerCommand = Command(HideBanner);
DismissBannerCommand = Command(HideBanner, allowedInMaintenance: true);
}
// ------------------------------------------------------------------ Propriétés affichées
@@ -184,6 +189,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(ShowInstallInfo));
OnPropertyChanged(nameof(ShowRailBadge));
OnPropertyChanged(nameof(RailToolTip));
OnPropertyChanged(nameof(DisplayStatusTitle));
OnPropertyChanged(nameof(DisplayStatusDetail));
RefreshCommands();
// Lancement demandé par un raccourci pendant une vérification : traité à la fin de celle-ci.
@@ -196,12 +203,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
}
public bool IsBusy => State == LauncherState.Busy;
public bool ShowLocateHint => State == LauncherState.NotInstalled;
public bool ShowLocateHint => State == LauncherState.NotInstalled && !_isMaintenance;
public bool ShowBrokenActions => State == LauncherState.Broken;
public bool ShowInstallInfo => InstallPath is not null && State != LauncherState.NotInstalled;
public string PrimaryText => State switch
{
not (LauncherState.Busy or LauncherState.Running) when _isMaintenance => "MAINTENANCE",
LauncherState.Checking => "CHARGEMENT",
LauncherState.NotInstalled => "INSTALLER",
LauncherState.Ready => "JOUER",
@@ -215,6 +223,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// <summary>Clé d'icône (ressource "Icon.&lt;clé&gt;" dans Themes/Icons.xaml).</summary>
public string PrimaryIcon => State switch
{
not (LauncherState.Busy or LauncherState.Running) when _isMaintenance => "TrafficCone",
LauncherState.Checking => "Refresh",
LauncherState.NotInstalled => "Download",
LauncherState.UpdateAvailable => "CloudDownload",
@@ -244,10 +253,71 @@ public sealed class GameViewModel : ObservableObject, IDisposable
};
private string _statusTitle = "Chargement…";
public string StatusTitle { get => _statusTitle; private set => SetProperty(ref _statusTitle, value); }
public string StatusTitle
{
get => _statusTitle;
private set { if (SetProperty(ref _statusTitle, value)) OnPropertyChanged(nameof(DisplayStatusTitle)); }
}
private string _statusDetail = "";
public string StatusDetail { get => _statusDetail; private set => SetProperty(ref _statusDetail, value); }
public string StatusDetail
{
get => _statusDetail;
private set { if (SetProperty(ref _statusDetail, value)) OnPropertyChanged(nameof(DisplayStatusDetail)); }
}
/// <summary>Titre de la barre d'action : pendant une maintenance, celle-ci (sauf pendant une opération déjà commencée).</summary>
public string DisplayStatusTitle => _isMaintenance && State != LauncherState.Busy ? MaintenanceRules.Title : StatusTitle;
public string DisplayStatusDetail => !_isMaintenance || State == LauncherState.Busy
? StatusDetail
: State == LauncherState.Running
? "Le jeu déjà lancé n'est pas fermé. Seule la désinstallation des jeux reste possible."
: "Seule la désinstallation des jeux reste possible (menu « … »).";
// ------------------------------------------------------------------ Mode maintenance
/// <summary>
/// Mode maintenance publié depuis le menu d'administration (commun à tous les jeux, posé par le launcher) : tout est
/// bloqué sauf la désinstallation. Une opération déjà commencée se termine (elle reste annulable) ; un jeu déjà lancé
/// n'est pas fermé. À la fin de la maintenance, les mises à jour sont recherchées de nouveau.
/// </summary>
public bool IsMaintenance
{
get => _isMaintenance;
set
{
if (!SetProperty(ref _isMaintenance, value)) return;
if (value) _launchWhenReady = false; // lancement demandé par un raccourci : abandonné
OnPropertyChanged(nameof(PrimaryText));
OnPropertyChanged(nameof(PrimaryIcon));
OnPropertyChanged(nameof(DisplayStatusTitle));
OnPropertyChanged(nameof(DisplayStatusDetail));
OnPropertyChanged(nameof(ShowLocateHint));
OnPropertyChanged(nameof(ShowWebsite));
RefreshCommands();
Log.Info($"[{GameId}] Mode maintenance {(value ? "activé" : "terminé")}.");
// Fin de la maintenance : état et mises à jour à jour (mise à jour automatique selon le réglage du jeu).
if (!value && State is LauncherState.NotInstalled or LauncherState.Ready or LauncherState.UpdateAvailable
or LauncherState.NeedsRepair or LauncherState.Broken)
Fire(() => CheckForUpdatesAsync(userInitiated: false));
}
}
/// <summary>Bouton « Site officiel » de la barre de titre (masqué pendant une maintenance).</summary>
public bool ShowWebsite => HasWebsite && !_isMaintenance;
/// <summary>Action refusée pendant une maintenance : bandeau d'explication. Vrai si l'action doit s'arrêter.</summary>
private bool BlockedByMaintenance()
{
if (!_isMaintenance) return false;
Log.Info($"[{GameId}] Action refusée : mode maintenance.");
ShowBanner(MaintenanceRules.Title,
"Cette action est indisponible pendant la maintenance. Merci de revenir plus tard : seule la désinstallation reste possible.",
DialogKind.Warning);
return true;
}
private string _stepText = "";
public string StepText { get => _stepText; private set => SetProperty(ref _stepText, value); }
@@ -334,9 +404,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
public RelayCommand OpenWebsiteCommand { get; }
public RelayCommand DismissBannerCommand { get; }
private RelayCommand Command(Action execute, Func<bool>? canExecute = null)
private RelayCommand Command(Action execute, Func<bool>? canExecute = null, bool allowedInMaintenance = false)
{
var command = new RelayCommand(execute, canExecute);
var command = new RelayCommand(execute,
allowedInMaintenance ? canExecute : () => !_isMaintenance && (canExecute?.Invoke() ?? true));
_commands.Add(command);
return command;
}
@@ -348,6 +419,8 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private bool CanExecutePrimary() => State switch
{
LauncherState.Busy when _isMaintenance => _operationCancellable, // seule l'annulation reste possible
_ when _isMaintenance => false,
LauncherState.Checking or LauncherState.Running => false,
LauncherState.Busy => _operationCancellable,
_ => true
@@ -355,6 +428,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private void ExecutePrimary()
{
if (State != LauncherState.Busy && BlockedByMaintenance()) return;
switch (State)
{
case LauncherState.NotInstalled: Fire(InstallAsync); break;
@@ -578,6 +652,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task CheckForUpdatesAsync(bool userInitiated, bool background = false)
{
if (State is LauncherState.Busy or LauncherState.Running) return;
// Maintenance : ni recherche ni mise à jour automatique (reprises à la fin de la maintenance).
if (_isMaintenance)
{
if (userInitiated) BlockedByMaintenance();
return;
}
if (_launcherUpdates?.IsInstalling == true) return; // le launcher se met à jour puis redémarre
if (!background)
@@ -599,6 +679,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
if (State is LauncherState.Busy or LauncherState.Running) return; // une opération ou le jeu a démarré entre-temps
if (_launcherUpdates?.IsInstalling == true) return;
RefreshInstallState();
if (_isMaintenance) return; // maintenance commencée pendant la recherche : pas de mise à jour automatique
if (manifest is null)
{
@@ -649,11 +730,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// Exécute une opération longue : état "Busy", barre de progression, annulation,
/// gestion des erreurs, puis nouvelle détection de l'état.
/// </summary>
/// <param name="allowedInMaintenance">Vrai pour la désinstallation seulement : seule opération permise pendant une maintenance.</param>
private async Task<bool> RunOperationAsync(string title,
Func<IProgress<OperationProgress>, CancellationToken, Task> work,
bool cancellable = true)
bool cancellable = true, bool allowedInMaintenance = false)
{
if (State == LauncherState.Busy) return false;
if (!allowedInMaintenance && BlockedByMaintenance()) return false;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
_operationCts = cts;
@@ -725,6 +808,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task InstallAsync()
{
if (BlockedByMaintenance()) return;
var manifest = await TryFetchManifestAsync();
if (manifest is null)
{
@@ -756,6 +840,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task UpdateAsync()
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
@@ -783,6 +868,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// </param>
private async Task RepairAsync(bool cacheIsFresh = false)
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
@@ -914,6 +1000,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task VerifyIntegrityAsync()
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
@@ -983,6 +1070,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private async Task LocateAsync()
{
if (BlockedByMaintenance()) return;
var initial = InstallPath is not null ? Path.GetDirectoryName(InstallPath) : null;
var folder = _dialogs.PickFolder($"Où se trouve {GameName} ?", initial);
if (folder is null) return;
@@ -1046,6 +1134,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private void Forget()
{
if (BlockedByMaintenance()) return;
if (!_dialogs.Confirm("Oublier l'installation",
$"Le launcher ne considérera plus {GameName} comme installé dans « {InstallPath} ».\nAucun fichier ne sera supprimé.",
"Oublier", "Annuler", DialogKind.Question))
@@ -1124,7 +1213,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
_registry.Delete();
_shortcuts.RemoveDesktopShortcut();
_needsRepair = false;
}, cancellable: false);
}, cancellable: false, allowedInMaintenance: true);
if (ok && result is not null)
{
@@ -1250,6 +1339,12 @@ public sealed class GameViewModel : ObservableObject, IDisposable
public void LaunchFromShortcut()
{
_launchWhenReady = false;
if (_isMaintenance && State != LauncherState.Running)
{
ShowBanner(MaintenanceRules.Title,
$"{GameName} ne peut pas être lancé pendant la maintenance. {MaintenanceRules.Subtitle}", DialogKind.Warning, autoHide: false);
return;
}
switch (State)
{
case LauncherState.Ready:
@@ -1283,6 +1378,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private void Play()
{
if (BlockedByMaintenance()) return;
var dir = InstallPath;
if (dir is null) return;
var exePath = GetExecutablePath(dir);
@@ -1416,6 +1512,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
/// <summary>Paramètres propres à ce jeu (menu « … »). Les réglages communs sont dans les paramètres du launcher.</summary>
private void OpenSettings()
{
if (BlockedByMaintenance()) return;
var info = new GameSettingsInfo(GameName, InstallPath, InstalledVersionText, LatestVersionText, InstallSizeText,
_config.GameLogsDirectory, _config.GameDataDirectory);
var shortcut = new GameShortcutActions(
@@ -116,9 +116,19 @@ public sealed class LauncherSettingsViewModel : SettingsViewModelBase
if (check.IsAvailable)
{
SetAvailable(check);
LauncherStatusKind = DialogKind.Info;
var title = string.IsNullOrWhiteSpace(check.Release?.Title) ? "" : $" — {check.Release!.Title}";
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
if (check.IsPriority)
{
// Correctif critique non installé au démarrage (annulé, échec, dossier protégé…)
LauncherStatusKind = DialogKind.Warning;
LauncherStatus = $"Mise à jour importante {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}). " +
"Installez-la dès que possible.";
}
else
{
LauncherStatusKind = DialogKind.Info;
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
}
}
else
{
@@ -21,13 +21,19 @@ public sealed class LauncherUpdateViewModel : ObservableObject
public LauncherUpdateViewModel(LauncherUpdateCheck update, bool automatic)
{
Automatic = automatic;
IsPriority = update.IsPriority;
FromVersion = update.CurrentDisplay;
ToVersion = update.AvailableDisplay;
// Notes de toutes les versions publiées depuis la version installée (format enrichi)
Changelog = update.Release?.GetChangelogSince(update.CurrentVersion) ?? Array.Empty<ChangelogEntry>();
Subtitle = automatic
? "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul."
: "Installation de la nouvelle version. Le launcher redémarrera tout seul.";
Subtitle = (automatic, IsPriority) switch
{
(true, true) => "Mise à jour importante : elle s'installe automatiquement, même si la mise à jour automatique " +
"du launcher est désactivée (votre réglage ne change pas). Le launcher redémarrera tout seul.",
(true, false) => "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul.",
(false, true) => "Installation d'une mise à jour importante. Le launcher redémarrera tout seul.",
_ => "Installation de la nouvelle version. Le launcher redémarrera tout seul."
};
Progress = new Progress<OperationProgress>(p =>
{
@@ -44,6 +50,9 @@ public sealed class LauncherUpdateViewModel : ObservableObject
/// <summary>Recherche au démarrage (vrai) ou demandée dans les Paramètres du launcher (faux).</summary>
public bool Automatic { get; }
/// <summary>Mise à jour prioritaire (correctif critique, information signée) : pastille « Prioritaire ».</summary>
public bool IsPriority { get; }
public string FromVersion { get; }
public string ToVersion { get; }
public string Subtitle { get; }
@@ -1,6 +1,8 @@
using System.Collections.ObjectModel;
using System.Globalization;
using System.IO;
using System.Text.Json;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
@@ -32,6 +34,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
private readonly string _launcherVersion;
private IThemeTransition? _transition;
private GameViewModel? _selectedGame;
private MaintenanceDocument? _maintenance;
public LauncherViewModel(LauncherCatalog catalog, IWindowService window, IDialogService dialogs,
LauncherPreferences preferences, string launcherVersion,
@@ -52,7 +55,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
if (CloseToTray) HideToTray(); // option « La croix réduit le launcher dans la zone de notification »
else Quit();
});
OpenLauncherSettingsCommand = new RelayCommand(OpenLauncherSettings);
OpenLauncherSettingsCommand = new RelayCommand(OpenLauncherSettings, () => !IsMaintenance);
}
public ObservableCollection<GameViewModel> Games { get; } = new();
@@ -65,9 +68,55 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
public bool IsExitConfirmed { get; set; }
/// <summary>Bouton « Aide » affiché (adresse de l'API présente dans launcher.json).</summary>
/// <summary>Bouton « Aide » disponible (adresse de l'API présente dans launcher.json).</summary>
public bool IsHelpAvailable { get; set; }
/// <summary>Bouton « Aide » affiché : disponible, et pas de maintenance en cours.</summary>
public bool ShowHelpButton => IsHelpAvailable && !IsMaintenance;
// ------------------------------------------------------------------ Mode maintenance
/// <summary>
/// Mode maintenance publié depuis le menu d'administration (maintenance.json, vérifié par MaintenanceService) : thème
/// rouge / orange, message au centre, tout bloqué sauf la désinstallation des jeux et le menu d'administration.
/// </summary>
public bool IsMaintenance => _maintenance is { Enabled: true };
public string MaintenanceTitle => MaintenanceRules.Title;
public string MaintenanceSubtitle => MaintenanceRules.Subtitle;
/// <summary>Précision publiée par l'administrateur (texte brut, sans lien), sinon null.</summary>
public string? MaintenanceMessage => IsMaintenance ? _maintenance!.Message : null;
/// <summary>« Depuis le mardi 10 novembre à 14:05 » (heure de ce PC).</summary>
public string? MaintenanceSinceText => IsMaintenance && _maintenance!.Since is { } since
? "Depuis le " + since.ToLocalTime().ToString("dddd d MMMM 'à' HH:mm", CultureInfo.GetCultureInfo("fr-FR"))
: null;
/// <summary>Nouvel état du mode maintenance (null : rien de publié). Appelé sur le fil de l'interface.</summary>
public void ApplyMaintenance(MaintenanceDocument? maintenance)
{
var wasActive = IsMaintenance;
var next = maintenance is { Enabled: true } ? maintenance : null;
void Change()
{
_maintenance = next;
var active = IsMaintenance;
foreach (var game in Games) game.IsMaintenance = active;
OnPropertyChanged(nameof(MaintenanceMessage));
OnPropertyChanged(nameof(MaintenanceSinceText));
if (wasActive == active) return;
OnPropertyChanged(nameof(IsMaintenance));
OnPropertyChanged(nameof(ShowHelpButton));
OpenLauncherSettingsCommand.RaiseCanExecuteChanged();
}
// Début ou fin de la maintenance : même fondu enchaîné qu'un changement de jeu (palette, carte au centre).
if (wasActive != (next is not null) && _transition is not null) _transition.Run(Change);
else Change();
}
public RelayCommand MinimizeCommand { get; }
public RelayCommand CloseCommand { get; }
@@ -124,6 +173,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
public void AddGame(GameViewModel game)
{
game.IsMaintenance = IsMaintenance;
Games.Add(game);
OnPropertyChanged(nameof(IsMultiGame));
}
@@ -253,6 +303,7 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
private void OpenLauncherSettings()
{
if (IsMaintenance) return; // bouton désactivé pendant une maintenance
var vm = new LauncherSettingsViewModel(_preferences, LauncherName, _launcherVersion, _window, _launcherUpdates);
if (!_dialogs.ShowSettings(vm)) return;
+88 -89
View File
@@ -3,6 +3,7 @@
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"
xmlns:conv="clr-namespace:GameLauncher.Converters"
Title="Aide"
Width="680"
SizeToContent="Height"
@@ -16,14 +17,20 @@
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
ui:WheelAssist.IsEnabled="True"
PreviewKeyDown="OnPreviewKeyDown">
<!--
Bouton « Aide » de la fenêtre principale : avis, demande d'évolution, bug ou autre, pour le jeu sélectionné.
ViewModel : FeedbackFormViewModel (GameLauncher.Modules.Feedback) ; formulaires décrits dans GameLauncher.Contracts.
Tout le texte saisi est affiché tel quel (jamais interprété) ; l'envoi est anonyme.
Hauteur : formulaire limité à une taille de base (ascenseur au-delà) ; la fenêtre reste centrée quand l'étape
change (voir FeedbackWindow.xaml.cs). Molette : WheelAssist, comme le menu d'administration (une liste
déroulante fermée ne change jamais de valeur, un champ en bout de course laisse défiler le formulaire).
-->
<Window.Resources>
<conv:BytesToImageConverter x:Key="FeedbackBytesToImage" />
<!-- Tuile du choix du type de demande -->
<Style x:Key="FeedbackKindTileStyle" TargetType="Button">
<Setter Property="Margin" Value="0,0,12,12" />
@@ -55,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">
@@ -160,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" />
@@ -179,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>
@@ -194,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" />
@@ -242,6 +189,55 @@
<!-- Champs prédéfinis du type choisi -->
<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 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"
ui:IconAssist.Icon="{StaticResource Icon.ImageOutline}" Command="{Binding AddFileCommand}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Coller (Ctrl+V)"
ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Command="{Binding PasteCommand}"
ToolTip="Colle l'image du presse-papiers (capture faite avec Win+Maj+S ou Impr. écran)" />
</StackPanel>
<TextBlock Text="{Binding AttachmentsText}" FontSize="11" Margin="2,6,0,0" Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="{Binding AttachmentError}" Style="{StaticResource FeedbackFieldErrorStyle}"
Visibility="{Binding HasAttachmentError, Converter={StaticResource BoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding Attachments}" Focusable="False" Margin="0,10,0,0"
Visibility="{Binding HasAttachments, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Width="170" Margin="0,0,10,10" CornerRadius="12" Padding="6" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<StackPanel>
<Grid>
<Border Height="92" CornerRadius="8" ClipToBounds="True" Background="{DynamicResource SurfaceAltBrush}">
<Image Source="{Binding Content, Converter={StaticResource FeedbackBytesToImage}, ConverterParameter=320}"
Stretch="Uniform" />
</Border>
<Button Style="{StaticResource IconButtonStyle}" Width="26" Height="26" Padding="7"
HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,4,4,0"
ToolTip="Retirer cette capture" ui:IconAssist.Icon="{StaticResource Icon.Close}"
Command="{Binding RemoveCommand}" />
</Grid>
<TextBlock Text="{Binding Name}" FontSize="11.5" Margin="2,6,2,0" TextTrimming="CharacterEllipsis"
ToolTip="{Binding Name}" />
<TextBlock Text="{Binding Details}" FontSize="10.5" Margin="2,2,2,0"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding ScreenshotPrivacyText}" FontSize="11" Margin="2,2,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding HasAttachments, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<!-- Informations techniques : facultatives, contenu exact affiché -->
<Border CornerRadius="14" Padding="14,4,14,12" Margin="0,4,0,12" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
@@ -289,6 +285,9 @@
</Border>
<TextBlock x:Name="CopiedText" Text="Référence copiée." FontSize="11.5" Margin="0,6,0,0" HorizontalAlignment="Center"
Foreground="{DynamicResource SuccessBrush}" Visibility="Collapsed" />
<TextBlock Text="{Binding AttachmentWarning}" FontSize="12" Margin="0,12,0,0" HorizontalAlignment="Center"
TextAlignment="Center" TextWrapping="Wrap" Foreground="{DynamicResource WarningBrush}"
Visibility="{Binding HasAttachmentWarning, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
+52 -3
View File
@@ -10,7 +10,11 @@ namespace GameLauncher.Views;
/// </summary>
public partial class FeedbackWindow : Window
{
/// <summary>Hauteur de base de la zone du formulaire : au-delà, il défile (ascenseur) au lieu d'agrandir la fenêtre.</summary>
private const double BodyMaxHeight = 500;
private readonly FeedbackFormViewModel _viewModel;
private bool _shown;
public FeedbackWindow(FeedbackFormViewModel viewModel)
{
@@ -20,11 +24,18 @@ public partial class FeedbackWindow : Window
viewModel.CloseRequested += Close;
viewModel.PropertyChanged += (_, e) =>
{
// Formulaire affiché : le curseur se place dans le titre.
// Nouvelle étape : le contenu repart du haut ; formulaire affiché : le curseur se place dans le titre.
if (e.PropertyName is nameof(FeedbackFormViewModel.IsFilling) or nameof(FeedbackFormViewModel.IsDone))
BodyScroll.ScrollToTop();
if (e.PropertyName == nameof(FeedbackFormViewModel.IsFilling) && viewModel.IsFilling)
Dispatcher.InvokeAsync(() => TitleBox.Focus(), System.Windows.Threading.DispatcherPriority.Input);
};
SourceInitialized += (_, _) => FitToScreen();
ContentRendered += (_, _) => _shown = true;
SizeChanged += (_, e) =>
{
if (_shown && e.HeightChanged) KeepCentered(e.PreviousSize.Height);
};
Closed += (_, _) =>
{
viewModel.CloseRequested -= Close;
@@ -32,11 +43,30 @@ public partial class FeedbackWindow : Window
};
}
/// <summary>La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente : au-delà, le formulaire défile.</summary>
/// <summary>
/// Taille de base raisonnable (<see cref="BodyMaxHeight"/>), jamais plus grande que la zone utile de l'écran de la
/// fenêtre parente : au-delà, le formulaire défile.
/// </summary>
private void FitToScreen()
{
const double chrome = 230; // en-tête, boutons, marges et ombre
BodyScroll.MaxHeight = Math.Max(260, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
var available = Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome;
BodyScroll.MaxHeight = Math.Max(260, Math.Min(BodyMaxHeight, available));
}
/// <summary>
/// La hauteur change avec l'étape (choix du type › formulaire › référence) : la fenêtre grandit ou rétrécit autour de
/// son centre (centre du launcher à l'ouverture), au lieu de s'étendre vers le bas de l'écran ; elle reste
/// entièrement dans la zone utile de l'écran. Une fenêtre placée volontairement hors des écrans (captures de
/// tools\UiSnapshot) n'est pas déplacée.
/// </summary>
private void KeepCentered(double previousHeight)
{
if (previousHeight <= 0 || double.IsNaN(Top) || double.IsNaN(Left) || WindowState != WindowState.Normal) return;
var work = Controls.WindowModeAssist.WorkArea(this);
if (work.Width < 1 || work.Height < 1 || !work.IntersectsWith(new Rect(Left, Top, ActualWidth, previousHeight))) return;
var top = Top + (previousHeight - ActualHeight) / 2;
Top = Math.Max(work.Top, Math.Min(top, work.Bottom - ActualHeight));
}
private void OnCopyReference(object sender, RoutedEventArgs e)
@@ -56,6 +86,25 @@ public partial class FeedbackWindow : Window
/// <summary>Échap ferme la fenêtre, sauf pendant la saisie d'un formulaire (pas de perte du texte par mégarde).</summary>
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
// Ctrl+V avec une image (et pas de texte) dans le presse-papiers : ajoutée comme capture, même depuis un champ.
if (e.Key == Key.V && Keyboard.Modifiers == ModifierKeys.Control && _viewModel.IsFilling && _viewModel.CanAddScreenshot)
{
bool hasImage;
try
{
hasImage = Clipboard.ContainsImage() && !Clipboard.ContainsText();
}
catch (System.Runtime.InteropServices.ExternalException)
{
hasImage = false; // presse-papiers occupé
}
if (hasImage)
{
_viewModel.PasteCommand.Execute(null);
e.Handled = true;
return;
}
}
if (e.Key != Key.Escape || _viewModel.IsFilling || _viewModel.IsSending) return;
Close();
e.Handled = true;
@@ -76,6 +76,16 @@
<TextBlock Text="{Binding ToVersion}" FontSize="13" FontWeight="Bold" VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- Mise à jour prioritaire (correctif critique) : installée même si l'option automatique est désactivée -->
<Border Style="{StaticResource VersionChipStyle}" Margin="10,0,0,0" BorderBrush="{DynamicResource WarningBrush}"
ToolTip="Correctif important : installé même si la mise à jour automatique du launcher est désactivée. Votre réglage ne change pas."
Visibility="{Binding IsPriority, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Data="{StaticResource Icon.Alert}" Fill="{DynamicResource WarningBrush}"
Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,7,0" />
<TextBlock Text="Prioritaire" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Border>
</StackPanel>
<!-- Nouveautés : même présentation que les notes du jeu (Themes/Changelog.xaml) -->
+97 -8
View File
@@ -271,14 +271,15 @@
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<!-- Aide : avis, demande d'évolution, bug… pour le jeu sélectionné (absent sans « apiUrl » dans launcher.json) -->
<!-- Aide : avis, demande d'évolution, bug… pour le jeu sélectionné (absent sans « apiUrl » dans launcher.json,
masqué pendant une maintenance) -->
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Aide : avis, idée, bug…" Margin="0,0,4,0"
ui:IconAssist.Icon="{StaticResource Icon.MessageQuestion}" Click="OnHelpClick"
Visibility="{Binding DataContext.IsHelpAvailable, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
Visibility="{Binding DataContext.ShowHelpButton, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Site officiel"
ui:IconAssist.Icon="{StaticResource Icon.Web}"
Command="{Binding OpenWebsiteCommand}"
Visibility="{Binding HasWebsite, Converter={StaticResource BoolToVisibility}}" />
Visibility="{Binding ShowWebsite, Converter={StaticResource BoolToVisibility}}" />
<!-- Paramètres du launcher : communs à tous les jeux -->
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Paramètres du launcher" Margin="4,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Cog}"
@@ -295,8 +296,9 @@
</StackPanel>
</Grid>
<!-- ===================================================== Contenu -->
<Grid Grid.Row="1" Margin="58,6,34,20">
<!-- ===================================================== Contenu (masqué pendant une maintenance) -->
<Grid Grid.Row="1" Margin="58,6,34,20"
Visibility="{Binding IsMaintenance, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<!-- Notes de version : 370 en mode fenêtré, plus larges en plein écran (jusqu'à 560) -->
@@ -489,6 +491,87 @@
</Border>
</Grid>
<!-- ===================================================== Mode maintenance (publié depuis le menu d'administration) -->
<!--
Carte au centre de la fenêtre, à la place de la présentation et des notes de version. La palette rouge / orange
(Themes/Maintenance.xaml) est ajoutée aux ressources de la fenêtre par MainWindow.xaml.cs pendant la maintenance.
Textes : ceux du launcher (titre, sous-titre) et, au choix, la précision publiée par l'administrateur (texte brut).
-->
<Grid Grid.Row="1" Margin="58,6,34,20"
Visibility="{Binding IsMaintenance, Converter={StaticResource BoolToVisibility}}">
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Focusable="False">
<Grid HorizontalAlignment="Center" VerticalAlignment="Center" MaxWidth="660" Margin="0,8">
<!-- Halo de la carte (à part : l'ombre ne s'applique pas aux textes) -->
<Border CornerRadius="28" Background="{DynamicResource MaintenanceCardBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="44" ShadowDepth="0" Opacity="0.5" Color="#FF3B1F" />
</Border.Effect>
</Border>
<Border CornerRadius="28" Padding="44,26,44,34" BorderThickness="1"
BorderBrush="{DynamicResource MaintenanceCardBorderBrush}">
<StackPanel>
<!-- Bandes de chantier -->
<Border Width="132" Height="8" CornerRadius="4" Margin="0,0,0,26" HorizontalAlignment="Center"
Background="{DynamicResource MaintenanceStripesBrush}" />
<Grid Width="92" Height="92" HorizontalAlignment="Center">
<Border CornerRadius="30" Background="{DynamicResource AccentGradientBrush}" Opacity="0.7">
<Border.Effect>
<BlurEffect Radius="26" />
</Border.Effect>
</Border>
<Border CornerRadius="30" Background="{DynamicResource AccentGradientBrush}">
<Path Data="{StaticResource Icon.TrafficCone}" Fill="{DynamicResource OnAccentBrush}"
Width="46" Height="46" Stretch="Uniform" />
</Border>
</Grid>
<TextBlock Text="{Binding DataContext.MaintenanceTitle, RelativeSource={RelativeSource AncestorType=Window}}"
Margin="0,26,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontFamily="{DynamicResource HeroTitleFont}" FontSize="38" FontWeight="{DynamicResource HeroTitleWeight}"
FontStretch="{DynamicResource HeroTitleStretch}" />
<TextBlock Text="{Binding DataContext.MaintenanceSubtitle, RelativeSource={RelativeSource AncestorType=Window}}"
Margin="0,8,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontSize="18" Foreground="{DynamicResource TextSecondaryBrush}" />
<Border Width="72" Height="3" CornerRadius="1.5" Margin="0,22,0,0" HorizontalAlignment="Center"
Background="{DynamicResource AccentGradientBrush}" />
<!-- Précision publiée par l'administrateur : texte brut, sélectionnable, jamais interprété -->
<TextBox Text="{Binding DataContext.MaintenanceMessage, RelativeSource={RelativeSource AncestorType=Window}, Mode=OneWay}"
Margin="0,20,0,0" IsReadOnly="True" IsTabStop="False" BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource TextPrimaryBrush}" CaretBrush="Transparent" Cursor="Arrow"
FontSize="15" TextAlignment="Center" TextWrapping="Wrap" HorizontalAlignment="Stretch"
Visibility="{Binding DataContext.MaintenanceMessage, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource StringToVisibility}}">
<TextBox.Template>
<ControlTemplate TargetType="TextBox">
<ScrollViewer x:Name="PART_ContentHost" Focusable="False" VerticalScrollBarVisibility="Disabled"
HorizontalScrollBarVisibility="Disabled" />
</ControlTemplate>
</TextBox.Template>
</TextBox>
<TextBlock Text="{Binding DataContext.MaintenanceSinceText, RelativeSource={RelativeSource AncestorType=Window}}"
Margin="0,18,0,0" HorizontalAlignment="Center" TextAlignment="Center" TextWrapping="Wrap"
FontSize="12.5" Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding DataContext.MaintenanceSinceText, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource StringToVisibility}}" />
<Border HorizontalAlignment="Center" Margin="0,18,0,0" CornerRadius="14" Padding="14,8"
Background="{DynamicResource BadgeBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
<DockPanel>
<Path DockPanel.Dock="Left" Data="{StaticResource Icon.Lock}" Fill="{DynamicResource Accent2Brush}"
Width="13" Height="13" Stretch="Uniform" VerticalAlignment="Center" />
<TextBlock Margin="9,0,0,0" FontSize="12.5" TextWrapping="Wrap" VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}"
Text="Installation, mises à jour et lancement suspendus. La désinstallation reste possible (menu « … »)." />
</DockPanel>
</Border>
</StackPanel>
</Border>
</Grid>
</ScrollViewer>
</Grid>
<!-- ===================================================== Barre d'action -->
<Border Grid.Row="2" Margin="24,0,24,24" Padding="28,20" CornerRadius="22"
Background="{DynamicResource ActionBarBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
@@ -500,7 +583,7 @@
<StackPanel VerticalAlignment="Center" Margin="0,0,32,0">
<Grid>
<TextBlock Text="{Binding StatusTitle}" FontSize="16" FontWeight="SemiBold"
<TextBlock Text="{Binding DisplayStatusTitle}" FontSize="16" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" Margin="0,0,90,0" />
<TextBlock Text="{Binding StepText}" HorizontalAlignment="Right" VerticalAlignment="Center"
FontSize="12" Foreground="{DynamicResource TextMutedBrush}" />
@@ -509,9 +592,9 @@
Value="{Binding ProgressValue, Mode=OneWay}"
IsIndeterminate="{Binding IsProgressIndeterminate, Mode=OneWay}"
Visibility="{Binding ShowProgress, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding StatusDetail}" Margin="0,8,0,0" FontSize="12.5"
<TextBlock Text="{Binding DisplayStatusDetail}" Margin="0,8,0,0" FontSize="12.5"
TextTrimming="CharacterEllipsis" Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding StatusDetail, Converter={StaticResource StringToVisibility}}" />
Visibility="{Binding DisplayStatusDetail, Converter={StaticResource StringToVisibility}}" />
<StackPanel Orientation="Horizontal" Margin="0,10,0,0"
Visibility="{Binding ShowLocateHint, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Le jeu est déjà sur votre PC ?" Margin="0,0,10,0" FontSize="12.5"
@@ -533,6 +616,12 @@
<DataTrigger Binding="{Binding State}" Value="Running">
<Setter Property="Background" Value="{DynamicResource NeutralGradientBrush}" />
</DataTrigger>
<!-- Maintenance : bouton « MAINTENANCE » désactivé, aux couleurs de la maintenance -->
<DataTrigger Binding="{Binding IsMaintenance}" Value="True">
<Setter Property="Background" Value="{DynamicResource AccentGradientBrush}" />
<Setter Property="ToolTip" Value="Maintenance en cours : merci de revenir plus tard." />
<Setter Property="ToolTipService.ShowOnDisabled" Value="True" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
+42
View File
@@ -22,10 +22,52 @@ public partial class MainWindow : Window, IThemeTransition
private readonly Queue<DateTime> _logoClicks = new();
/// <summary>Palette rouge / orange du mode maintenance (Themes/Maintenance.xaml), ajoutée aux ressources de la fenêtre.</summary>
private static readonly Uri MaintenancePaletteUri = new("pack://application:,,,/GameLauncher;component/Themes/Maintenance.xaml");
private ResourceDictionary? _maintenancePalette;
private LauncherViewModel? _observed;
public MainWindow()
{
InitializeComponent();
PreviewKeyDown += OnPreviewKeyDown;
DataContextChanged += (_, _) => ObserveLauncher(DataContext as LauncherViewModel);
}
// ------------------------------------------------------------------ Mode maintenance
private void ObserveLauncher(LauncherViewModel? launcher)
{
if (_observed is not null) _observed.PropertyChanged -= OnLauncherPropertyChanged;
_observed = launcher;
if (launcher is null) return;
launcher.PropertyChanged += OnLauncherPropertyChanged;
ApplyMaintenancePalette(launcher.IsMaintenance);
}
private void OnLauncherPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(LauncherViewModel.IsMaintenance) && sender is LauncherViewModel launcher)
ApplyMaintenancePalette(launcher.IsMaintenance);
}
/// <summary>
/// Maintenance : la palette rouge / orange passe devant celle du thème du jeu, pour cette fenêtre seulement (les
/// ressources de Root sont trouvées avant celles de l'application). Le fondu enchaîné est fait par LauncherViewModel
/// (début et fin de la maintenance passent par <see cref="Run"/>, comme un changement de jeu).
/// </summary>
private void ApplyMaintenancePalette(bool active)
{
var merged = Root.Resources.MergedDictionaries;
if (active)
{
_maintenancePalette ??= new ResourceDictionary { Source = MaintenancePaletteUri };
if (!merged.Contains(_maintenancePalette)) merged.Add(_maintenancePalette);
}
else if (_maintenancePalette is not null)
{
merged.Remove(_maintenancePalette);
}
}
/// <summary>
@@ -0,0 +1,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));
}
}
@@ -113,7 +113,77 @@ public class FeedbackPanelTests
private sealed class NeverSender : IFeedbackSender
{
public Task<string> SendAsync(FeedbackSubmission submission, CancellationToken ct) => throw new InvalidOperationException();
public Task<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, CancellationToken ct) =>
throw new InvalidOperationException();
}
/// <summary>Source simulée : l'image « choisie » ou « collée » est donnée par le test.</summary>
private sealed class FakeScreenshots : IScreenshotSource
{
public ScreenshotResult? Next { get; set; }
public ScreenshotResult? PickFile() => Next;
public ScreenshotResult? FromClipboard() => Next;
}
[Fact]
public async Task Player_screenshots_are_checked_then_sent_and_shown_to_admins_with_a_link_warning()
{
await using var api = await StartApiAsync();
using var client = api.CreateClient();
var screenshots = new FakeScreenshots();
using var form = new FeedbackFormViewModel(new FeedbackTarget("EZR", "Exovival Zombie Reborn", "0.9.3", "2.6.0"),
new ApiFeedbackSender(client), screenshots: screenshots);
form.Kinds.Single(k => k.Form.Kind == FeedbackKinds.Other).SelectCommand.Execute(null);
form.Title = "Un joueur m'a envoyé ce lien";
Field(form, "message").Text = "Il disait : www.cadeaux-gratuits.xyz/skin";
// Fichier qui n'est pas une image, presse-papiers vide : refusés avec un message.
screenshots.Next = new ScreenshotResult(System.Text.Encoding.UTF8.GetBytes("MZ"), "virus.png", null);
form.AddFileCommand.Execute(null);
Assert.True(form.HasAttachmentError);
screenshots.Next = null;
form.PasteCommand.Execute(null);
Assert.Contains("presse-papiers", form.AttachmentError);
screenshots.Next = new ScreenshotResult(GameLauncher.Tests.Contracts.FeedbackTestImages.PngWith("tEXt", "Nicolas"), "capture.png", null);
form.AddFileCommand.Execute(null);
Assert.False(form.HasAttachmentError, form.AttachmentError);
Assert.Contains("64 × 48 · PNG", Assert.Single(form.Attachments).Details);
await form.SendCommand.ExecuteAsync();
Assert.True(form.IsDone, form.ErrorMessage);
Assert.False(form.HasAttachmentWarning, form.AttachmentWarning);
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
AdminTestKit.RegisterOwner(api, signer);
signer.Dispose();
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, new FakeDialogs(), Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(FeedbackPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<FeedbackPanelViewModel>(workspace.SelectedPanel!.ViewModel);
var item = Assert.Single(page.Items); // un seul jeu déclaré : choisi d'office
Assert.True(item.HasLinks);
Assert.True(item.HasAttachments);
page.SelectedItem = item;
await page.DetailLoad;
var detail = page.Detail!;
Assert.True(detail.HasLinks);
Assert.Contains("www[.]cadeaux-gratuits[.]xyz/skin", detail.LinksText);
Assert.Contains(detail.Lines, l => l.HasLink);
var attachment = Assert.Single(detail.Attachments);
Assert.True(attachment.HasContent, attachment.Error);
attachment.ToggleCommand.Execute(null);
Assert.True(attachment.IsExpanded);
}
[Fact]
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", "feedback.forms", "audit.purge", "keyring.publish", "release.sign" })
Assert.NotEqual(action, GameLauncher.Modules.Admin.Audit.AuditTexts.Action(action));
}
// ------------------------------------------------------------------ Administrateurs
@@ -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,167 @@
using System.Buffers.Binary;
using System.Text;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Contracts;
/// <summary>Images fabriquées pour les captures d'écran : métadonnées à retirer, fichiers piégés.</summary>
public static class FeedbackTestImages
{
/// <summary>PNG valide avec un bloc ajouté juste après l'en-tête (texte, eXIf…).</summary>
public static byte[] PngWith(string chunkType, string content, int width = 64, int height = 48)
{
var png = TestImages.Png(width, height);
var chunk = Chunk(chunkType, Encoding.ASCII.GetBytes(content));
const int afterHeader = 8 + 25;
return png[..afterHeader].Concat(chunk).Concat(png[afterHeader..]).ToArray();
}
/// <summary>JPEG structurellement complet (JFIF, EXIF, tables, trame, scan, fin) — suffisant pour le contrôle commun.</summary>
public static byte[] Jpeg(int width = 64, int height = 48, string exif = "Exif\0\0GPS 48.85N 2.35E")
{
var b = new List<byte> { 0xFF, 0xD8 };
b.AddRange(new byte[] { 0xFF, 0xE0, 0x00, 0x10, (byte)'J', (byte)'F', (byte)'I', (byte)'F', 0, 1, 1, 0, 0, 1, 0, 1, 0, 0 });
var exifBytes = Encoding.ASCII.GetBytes(exif);
b.AddRange(new byte[] { 0xFF, 0xE1, (byte)((exifBytes.Length + 2) >> 8), (byte)(exifBytes.Length + 2) });
b.AddRange(exifBytes);
b.AddRange(new byte[] { 0xFF, 0xFE, 0x00, 0x07, (byte)'n', (byte)'o', (byte)'t', (byte)'e', (byte)'!' }); // commentaire
b.AddRange(new byte[] { 0xFF, 0xDB, 0x00, 0x43, 0x00 }.Concat(Enumerable.Repeat((byte)1, 64)));
b.AddRange(new byte[] { 0xFF, 0xC0, 0x00, 0x0B, 8, (byte)(height >> 8), (byte)height, (byte)(width >> 8), (byte)width, 1, 1, 0x11, 0 });
b.AddRange(new byte[] { 0xFF, 0xC4, 0x00, 0x14, 0x00 }.Concat(new byte[16] { 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }).Append((byte)0));
b.AddRange(new byte[] { 0xFF, 0xDA, 0x00, 0x08, 1, 1, 0x00, 0, 63, 0 });
b.AddRange(new byte[] { 0x12, 0x34, 0xFF, 0x00, 0x56 }); // données compressées (avec un FF 00)
b.AddRange(new byte[] { 0xFF, 0xD9 });
return b.ToArray();
}
public static byte[] Chunk(string type, byte[] data)
{
var typeAndData = Encoding.ASCII.GetBytes(type).Concat(data).ToArray();
var result = new byte[12 + data.Length];
BinaryPrimitives.WriteInt32BigEndian(result, data.Length);
typeAndData.CopyTo(result, 4);
BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(8 + data.Length), Crc(typeAndData));
return result;
}
private static uint Crc(byte[] data)
{
var crc = 0xFFFFFFFFu;
foreach (var b in data)
{
crc ^= b;
for (var k = 0; k < 8; k++) crc = (crc & 1) != 0 ? 0xEDB88320u ^ (crc >> 1) : crc >> 1;
}
return crc ^ 0xFFFFFFFFu;
}
}
/// <summary>Contenu des demandes : liens repérés (avertissement) et captures d'écran (vraie image, métadonnées retirées).</summary>
public class FeedbackContentTests
{
[Theory]
[InlineData("Allez sur https://exemple.com/cadeau", "https://exemple.com/cadeau")]
[InlineData("lien http://192.168.1.10:8080/x)", "http://192.168.1.10:8080/x")]
[InlineData("ftp://serveur.net/fichier.exe", "ftp://serveur.net/fichier.exe")]
[InlineData("voir www.arnaque.fr", "www.arnaque.fr")]
[InlineData("WWW.SITE.COM/promo.", "WWW.SITE.COM/promo")]
[InlineData("rejoins discord.gg/abcd", "discord.gg/abcd")]
[InlineData("bit.ly/3xYz", "bit.ly/3xYz")]
[InlineData("hxxps://mal[.]ru/a", "hxxps://mal[.]ru/a")]
[InlineData("http[:]//exemple.com", "http[:]//exemple.com")]
[InlineData("exemple[.]com", "exemple[.]com")]
[InlineData("exemple (dot) com", "exemple (dot) com")]
[InlineData("javascript:alert(1)", "javascript:alert(1)")]
[InlineData("data:text/html;base64,AAAA", "data:text/html;base64,AAAA")]
[InlineData("ouvrir \\\\serveur\\partage\\x.exe", "\\\\serveur\\partage\\x.exe")]
[InlineData("écrivez à joueur@mail.fr", "joueur@mail.fr")]
[InlineData("serveur 51.75.10.2", "51.75.10.2")]
[InlineData("https://exemple.com", "https://exemple.com")]
public void Links_are_found_in_all_their_forms(string text, string expected) =>
Assert.Contains(expected, FeedbackLinks.Find(text));
[Theory]
[InlineData("Le jeu plante quand j'ouvre l'inventaire.")]
[InlineData("Version 0.9.3, Windows 11, erreur HTTP 500 au lancement.")]
[InlineData("Le fichier Player.log fait 2 Mo ; 3 FPS à 1920×1080.")]
public void Ordinary_text_has_no_link(string text) => Assert.Empty(FeedbackLinks.Find(text));
[Fact]
public void Links_are_defanged_for_display()
{
Assert.Equal("hxxps[://]exemple[.]com/a", FeedbackLinks.Defang("https://exemple.com/a"));
Assert.Equal("joueur[@]mail[.]fr", FeedbackLinks.Defang("joueur@mail.fr"));
}
[Fact]
public void Png_metadata_is_removed_and_the_image_kept()
{
var png = FeedbackTestImages.PngWith("tEXt", "Author\0Nicolas");
Assert.Null(FeedbackImages.Sanitize(png, out var info, out var clean));
Assert.Equal((FeedbackImages.Png, 64, 48), (info!.Format, info.Width, info.Height));
Assert.DoesNotContain("Nicolas", Encoding.ASCII.GetString(clean!));
Assert.Null(FeedbackImages.Sanitize(clean, out _, out var again)); // l'image nettoyée reste valide
Assert.Equal(clean, again);
}
[Fact]
public void Jpeg_exif_and_comments_are_removed()
{
Assert.Null(FeedbackImages.Sanitize(FeedbackTestImages.Jpeg(), out var info, out var clean));
Assert.Equal(FeedbackImages.Jpeg, info!.Format);
var text = Encoding.ASCII.GetString(clean!);
Assert.DoesNotContain("GPS", text);
Assert.DoesNotContain("note!", text);
Assert.Contains("JFIF", text);
}
[Fact]
public void Fake_or_broken_images_are_refused()
{
var png = TestImages.Png(64, 48);
var refused = new Dictionary<string, byte[]>
{
["texte renommé"] = Encoding.UTF8.GetBytes("<html><script>alert(1)</script></html>"),
["GIF"] = Encoding.ASCII.GetBytes("GIF89a").Concat(new byte[64]).ToArray(),
["PNG tronqué"] = png[..^20],
["PNG suivi de données"] = png.Concat(Encoding.ASCII.GetBytes("PK\x03\x04 archive cachée")).ToArray(),
["PNG avec CRC faux"] = Flip(png, 30),
["PNG trop petit pour ses dimensions"] = Header(TestImages.Png(64, 48), width: 640),
["PNG à bloc critique inconnu"] = FeedbackTestImages.PngWith("XYZW", "?"),
["JPEG sans scan"] = TestImages.Jpeg(64, 48),
["JPEG suivi de données"] = FeedbackTestImages.Jpeg().Concat(new byte[] { 1, 2, 3 }).ToArray(),
["image démesurée"] = TestImages.Png(5000, 20)
};
foreach (var (name, data) in refused)
{
Assert.True(FeedbackImages.Sanitize(data, out var info, out var clean) is not null, name);
Assert.Null(clean);
}
}
[Fact]
public void Input_formats_are_recognized_by_content()
{
Assert.Equal("png", FeedbackImages.DetectInputFormat(TestImages.Png(16, 16)));
Assert.Equal("gif", FeedbackImages.DetectInputFormat(Encoding.ASCII.GetBytes("GIF89a......")));
Assert.Equal("webp", FeedbackImages.DetectInputFormat(Encoding.ASCII.GetBytes("RIFF\0\0\0\0WEBPVP8 ")));
Assert.Null(FeedbackImages.DetectInputFormat(Encoding.ASCII.GetBytes("MZ\x90\0 exécutable")));
}
private static byte[] Flip(byte[] data, int index)
{
var copy = (byte[])data.Clone();
copy[index] ^= 0xFF;
return copy;
}
/// <summary>Change la largeur annoncée par l'en-tête (CRC recalculé) : les données ne correspondent plus.</summary>
private static byte[] Header(byte[] png, int width)
{
var body = png.AsSpan(16, 13).ToArray();
BinaryPrimitives.WriteInt32BigEndian(body, width);
var chunk = FeedbackTestImages.Chunk("IHDR", body);
return png[..8].Concat(chunk).Concat(png[(8 + 25)..]).ToArray();
}
}
@@ -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 = "Invisible​ici" })),
("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("Texte‮esrevni", out problem); // inversion du sens d'écriture
Assert.NotNull(problem);
}
[Fact]
public void Documents_are_validated_and_signed_on_a_canonical_digest()
{
var errors = new List<string>();
Assert.True(MaintenanceRules.Validate(Active(message: "Mise à jour des serveurs."), errors), string.Join(" ", errors));
Assert.True(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false }, errors));
Assert.False(MaintenanceRules.Validate(Active() with { Revision = 0 }, errors = new()));
Assert.False(MaintenanceRules.Validate(Active() with { Since = null }, errors = new()));
Assert.False(MaintenanceRules.Validate(Active() with { Since = Since.AddMilliseconds(500) }, errors = new()));
Assert.False(MaintenanceRules.Validate(Active(message: " non nettoyé "), errors = new()));
Assert.False(MaintenanceRules.Validate(Active(message: "Voir www.site.com"), errors = new()));
Assert.False(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false, Message = "x" }, errors = new()));
Assert.False(MaintenanceRules.Validate(new MaintenanceDocument { Revision = 2, Enabled = false, Since = Since }, errors = new()));
Assert.False(MaintenanceRules.Validate(Active() with { Format = "autre" }, errors = new()));
// Début dans le futur (horloge du PC de l'administrateur déréglée) : refusé par le serveur.
Assert.False(MaintenanceRules.Validate(Active(), errors = new(), now: Since - TimeSpan.FromHours(1)));
// Condensé : état, message, début et révision couverts ; date de publication et signature non.
var digest = MaintenanceRules.Digest(Active(message: "A"));
Assert.Equal(digest, MaintenanceRules.Digest(Active(message: "A") with { UpdatedAt = DateTimeOffset.UtcNow }));
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(message: "B")));
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(2, "A")));
Assert.NotEqual(digest, MaintenanceRules.Digest(Active(message: "A") with { Since = Since.AddSeconds(1) }));
Assert.NotEqual(MaintenanceRules.Digest(Active()), MaintenanceRules.Digest(new MaintenanceDocument { Revision = 1 }));
// Aller-retour du fichier public.
var parsed = MaintenanceRules.Parse(System.Text.Encoding.UTF8.GetBytes(MaintenanceRules.Serialize(Active(message: "A"))))!;
Assert.Equal(digest, MaintenanceRules.Digest(parsed));
}
[Fact]
public void Maintenance_has_its_own_permission_and_keyring_scope()
{
Assert.Contains(AdminPermissions.MaintenanceManage, AdminPermissions.All);
Assert.Equal("Mode maintenance", AdminPermissions.BuiltInLabel(AdminPermissions.MaintenanceManage));
Assert.True(AdminRoles.Has(AdminRoles.Owner, AdminPermissions.MaintenanceManage));
Assert.True(AdminRoles.Has(AdminRoles.Admin, AdminPermissions.MaintenanceManage));
Assert.False(AdminRoles.Has(AdminRoles.Editor, AdminPermissions.MaintenanceManage));
Assert.Equal(SigningScopes.Maintenance, ContentKinds.ScopeOf(ContentKinds.Maintenance));
Assert.Equal(ContentKinds.MaintenanceId, ContentSigning.SignedId(ContentKinds.Maintenance, "autre"));
Assert.Equal(new[] { SigningScopes.Maintenance }, SigningScopes.FromPermissions(new[] { AdminPermissions.MaintenanceManage }));
Assert.Equal("mode maintenance", SigningScopes.DisplayName(SigningScopes.Maintenance));
// Une clé autorisée « catalog » seulement ne peut pas signer le mode maintenance.
using var key = Signing.SigningKit.NewKey();
using var root = Signing.SigningKit.NewKey();
var document = Active();
var signature = Signing.SigningKit.Sign(key, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document));
var catalogOnly = Signing.SigningKit.Keyring(root, 1, Signing.SigningKit.Entry(key, "Léo", SigningScopes.Catalog));
Assert.False(ContentSigning.Verify(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signature, catalogOnly).Valid);
var allowed = Signing.SigningKit.Keyring(root, 2, Signing.SigningKit.Entry(key, "Nicolas", SigningScopes.Maintenance));
Assert.True(ContentSigning.Verify(ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signature, allowed).Valid);
// Signature du mode maintenance réutilisée pour un autre contenu : refusée (séparation des types).
Assert.False(ContentSigning.VerifyWithKey(ContentKinds.Catalog, ContentKinds.CatalogId, MaintenanceRules.Digest(document), signature,
key.ExportSubjectPublicKeyInfo()));
}
}
@@ -7,6 +7,7 @@
et attaquée comme le ferait un client réel ou malveillant.
• Admin : module d'administration du launcher (clés, connexion, invitation, verrouillage,
écrans) contre ce même serveur, sans interface graphique.
• Website : site vitrine (signature de launcher-update.json, manifests, liseuse Markdown).
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -35,6 +36,18 @@
<ProjectReference Include="..\..\src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
</ItemGroup>
<!--
Site vitrine : ses services sont compilés ici plutôt que référencés. Une référence au projet du site
copierait son appsettings.json dans le dossier des tests, à la place de celui de la Launcher API
(même nom de fichier), et fausserait les tests du serveur.
-->
<ItemGroup>
<ProjectReference Include="..\..\src\GameLauncher.Core\GameLauncher.Core.csproj" />
<Compile Include="..\..\website\GameLauncher.Website\Services\*.cs" Link="Website\Site\%(Filename)%(Extension)" />
<Compile Include="..\..\website\GameLauncher.Website\Options\*.cs" Link="Website\Site\%(Filename)%(Extension)" />
<EmbeddedResource Include="..\..\src\GameLauncher\Security\update-signing.pub" Link="Website\Site\update-signing.pub" LogicalName="GameLauncher.Website.update-signing.pub" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
@@ -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);
}
}
@@ -7,6 +7,7 @@ using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using GameLauncher.Tests.Contracts;
using GameLauncher.Tests.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -331,4 +332,81 @@ public class FeedbackModuleTests
Assert.Empty(store.List(Game, null, null, null, 100).Items);
Assert.Empty(Directory.GetFiles(Path.Combine(api.DataDirectory, "feedback"), "*.json", SearchOption.AllDirectories));
}
[Fact]
public async Task Screenshots_are_checked_cleaned_and_bound_to_their_request()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
var png = FeedbackTestImages.PngWith("tEXt", "Author\0Nicolas");
var sent = await client.SendFeedbackAsync(Bug(), new[] { png, FeedbackTestImages.Jpeg() });
Assert.Equal((2, (string?)null), (sent.AttachmentsSent, sent.AttachmentError));
Assert.NotNull(sent.Submitted.UploadToken);
// Fichiers privés, sans métadonnées (ni texte PNG, ni EXIF/position).
var files = Directory.GetFiles(Path.Combine(api.DataDirectory, "feedback", Game), "*-*.*", SearchOption.AllDirectories);
Assert.Equal(2, files.Length);
Assert.All(files, f =>
{
var text = System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(f));
Assert.DoesNotContain("Nicolas", text);
Assert.DoesNotContain("GPS", text);
});
var upload = Assert.Single(Journal(api, "feedback.attachment").Where(e => e.Result == AuditResults.Ok).Take(1));
Assert.Null(upload.Ip);
// Jeton : seulement pour cette demande, le nombre annoncé, et une vraie image.
async Task<HttpStatusCode> PutAsync(int index, string? token, byte[] body)
{
using var request = new HttpRequestMessage(HttpMethod.Put, ApiRoutes.FeedbackAttachment(index)) { Content = new ByteArrayContent(body) };
if (token is not null) request.Headers.Add(FeedbackUpload.TokenHeader, token);
using var response = await api.Http.SendAsync(request);
return response.StatusCode;
}
var token = sent.Submitted.UploadToken!;
Assert.Equal(HttpStatusCode.Forbidden, await PutAsync(3, token, png)); // au-delà du nombre annoncé
Assert.Equal(HttpStatusCode.Forbidden, await PutAsync(1, null, png)); // sans jeton
Assert.Equal(HttpStatusCode.Forbidden, await PutAsync(1, token[..^4] + "AAAA", png)); // jeton modifié
Assert.Equal(HttpStatusCode.BadRequest, await PutAsync(1, token, System.Text.Encoding.UTF8.GetBytes("<script>")));
api.Time.Advance(TimeSpan.FromMinutes(FeedbackUpload.TokenLifetimeMinutes + 1));
Assert.Equal(HttpStatusCode.Forbidden, await PutAsync(1, token, png)); // délai dépassé
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var item = Assert.Single((await owner.GetFeedbackPageAsync(Game)).Items);
Assert.Equal(2, item.AttachmentCount);
var detail = await owner.GetFeedbackAsync(Game, item.Id);
Assert.Equal(new[] { "png", "jpg" }, detail.Attachments.Select(a => a.Format).ToArray());
var image = await owner.GetFeedbackAttachmentAsync(Game, item.Id, 1);
Assert.Null(FeedbackImages.Sanitize(image, out _, out var clean));
Assert.Equal(clean, image); // déjà propre
await owner.DeleteFeedbackAsync(Game, item.Id);
Assert.Empty(Directory.GetFiles(Path.Combine(api.DataDirectory, "feedback", Game), "*", SearchOption.AllDirectories));
}
[Fact]
public async Task Refused_screenshot_does_not_cancel_the_request()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
var sent = await client.SendFeedbackAsync(Bug(), new[] { System.Text.Encoding.UTF8.GetBytes("pas une image") });
Assert.Equal(0, sent.AttachmentsSent);
Assert.Contains("PNG ou JPEG", sent.AttachmentError);
Assert.True(FeedbackRules.IsValidReference(sent.Submitted.Reference));
}
[Fact]
public async Task Requests_with_links_are_flagged()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
await client.SendFeedbackAsync(Bug() with { Fields = new(Bug().Fields) { ["actual"] = "Voir hxxps://cadeau[.]ru" } });
await client.SendFeedbackAsync(Bug(title: "Sans lien dans le texte"));
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var items = (await owner.GetFeedbackPageAsync(Game)).Items;
Assert.True(items.Single(i => i.Title != "Sans lien dans le texte").HasLinks);
Assert.False(items.Single(i => i.Title == "Sans lien dans le texte").HasLinks);
}
}
@@ -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,139 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
namespace GameLauncher.Tests.Signing;
/// <summary>
/// Version PRIORITAIRE du launcher (correctif critique) : installée au démarrage même si le joueur a désactivé la mise
/// à jour automatique. Information signée, reprise d'une publication à l'autre, sans effet sur les launchers à jour.
/// </summary>
public class LauncherPriorityTests
{
private static LauncherRelease Release(string version, string? priority = null) => new()
{
Product = "GameLauncher",
Version = version,
File = $"GameLauncher-{version}.exe",
Size = 1234,
Sha256 = new string('a', 64),
PublishedAt = DateTimeOffset.UnixEpoch,
PriorityVersion = priority
};
private static (string Envelope, UpdateSignature.TrustedKey Key) Sign(LauncherRelease release, ECDsa key)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options);
var envelope = UpdateSignature.CreateEnvelope(payload, key);
var spki = key.ExportSubjectPublicKeyInfo();
return (envelope, new UpdateSignature.TrustedKey(UpdateSignature.ComputeKeyId(spki), spki));
}
private static LauncherUpdateCheck Check(string current, LauncherRelease? release) =>
new(VersionUtil.Parse(current), release, release is null ? null : new Uri("https://jeux.exemple.test/launcher/" + release.File));
[Fact]
public void The_priority_version_is_part_of_the_signed_content()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var (envelope, trusted) = Sign(Release("3.4.0", priority: "3.4.0"), key);
var read = UpdateSignature.ReadRelease(envelope, new[] { trusted });
Assert.Equal("3.4.0", read.PriorityVersion);
Assert.True(read.IsPriorityRelease);
// Serveur compromis : retirer (ou ajouter) la priorité dans le payload casse la signature.
var json = JsonSerializer.Deserialize<SignedEnvelope>(envelope, JsonDefaults.Options)!;
var tampered = Encoding.UTF8.GetString(Convert.FromBase64String(json.Payload)).Replace("\"3.4.0\"", "\"3.3.9\"");
json.Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(tampered));
var forged = JsonSerializer.Serialize(json, JsonDefaults.Options);
Assert.Throws<UpdateSecurityException>(() => UpdateSignature.ReadRelease(forged, new[] { trusted }));
}
[Fact]
public void A_release_without_priority_stays_compatible_with_older_launchers()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var release = Release("3.4.0");
var payload = Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options));
Assert.DoesNotContain("priorityVersion", payload, StringComparison.OrdinalIgnoreCase); // champ absent : rien ne change
var (envelope, trusted) = Sign(release, key);
var read = UpdateSignature.ReadRelease(envelope, new[] { trusted });
Assert.Null(read.PriorityVersion);
Assert.False(read.IsPriorityRelease);
Assert.False(Check("3.3.1", read).IsPriority);
Assert.True(Check("3.3.1", read).IsAvailable);
}
[Theory]
[InlineData("3.3.1", true)] // plus ancien que le correctif : mise à jour imposée
[InlineData("3.3.9", true)]
[InlineData("3.4.0", false)] // correctif déjà installé : réglage du joueur respecté pour 3.5.0
[InlineData("3.4.2", false)]
public void Only_launchers_older_than_the_priority_version_are_forced(string current, bool forced)
{
var release = Release("3.5.0", priority: "3.4.0"); // 3.4.0 prioritaire, puis 3.5.0 normale
Assert.Equal(forced, release.IsPriorityFor(VersionUtil.Parse(current)));
Assert.Equal(forced, Check(current, release).IsPriority);
Assert.False(release.IsPriorityRelease);
}
[Fact]
public void No_update_available_means_no_priority()
{
Assert.False(Check("3.4.0", null).IsPriority);
Assert.False(new LauncherUpdateCheck(VersionUtil.Parse("3.3.0"), Release("3.4.0", "3.4.0"), null).IsPriority);
}
[Theory]
[InlineData("3.6.0")] // supérieure à la version publiée
[InlineData("pas-une-version")]
public void An_inconsistent_priority_version_is_refused(string priority)
{
var release = Release("3.5.0", priority);
Assert.Throws<UpdateSecurityException>(release.Validate);
}
[Fact]
public void A_blank_priority_version_means_none()
{
var release = Release("3.5.0", " ");
release.Validate();
Assert.Null(release.PriorityVersion);
}
[Fact]
public void The_publisher_marks_carries_over_or_clears_the_priority()
{
var v350 = VersionUtil.Parse("3.5.0");
var previousPriority = Release("3.4.0", priority: "3.4.0");
var previousNormal = Release("3.4.0");
// --priority : la version publiée elle-même
Assert.Equal("3.5.0", LauncherRelease.ResolvePriorityVersion(v350, markPriority: true, clearPriority: false, previousNormal));
// version normale après un correctif critique : priorité reprise (les launchers < 3.4.0 restent forcés)
Assert.Equal("3.4.0", LauncherRelease.ResolvePriorityVersion(v350, false, false, previousPriority));
// --clear-priority : plus aucune version imposée
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, true, previousPriority));
// rien à reprendre
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, false, previousNormal));
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, false, null));
// priorité précédente supérieure à la version publiée (--force vers une version inférieure) : non reprise
Assert.Null(LauncherRelease.ResolvePriorityVersion(VersionUtil.Parse("3.3.5"), false, false, previousPriority));
// options contradictoires
Assert.Throws<ArgumentException>(() => LauncherRelease.ResolvePriorityVersion(v350, true, true, null));
}
[Fact]
public void The_player_setting_file_has_no_priority_field()
{
// Effet ponctuel : la priorité vient UNIQUEMENT de la description signée, jamais de self-update.json.
var settings = new SelfUpdateSettings { AutoUpdate = false };
var json = JsonSerializer.Serialize(settings, JsonDefaults.Options);
Assert.DoesNotContain("priority", json, StringComparison.OrdinalIgnoreCase);
Assert.False(JsonSerializer.Deserialize<SelfUpdateSettings>(json, JsonDefaults.Options)!.AutoUpdate);
}
}
@@ -0,0 +1,248 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts.Maintenance;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Maintenance;
using GameLauncher.Core.Trust;
using GameLauncher.Tests.Admin;
namespace GameLauncher.Tests.Signing;
/// <summary>Mode maintenance lu par le launcher des joueurs : vérification, retour en arrière, hors ligne, changements signalés.</summary>
public class MaintenanceServiceTests
{
private static readonly Uri KeyringUrl = new("https://launcher.example/catalog/keyring.json");
private static readonly Uri MaintenanceUrl = new("https://launcher.example/catalog/maintenance.json");
/// <summary>Serveur simulé : trousseau et maintenance.json (absent : 404 ; Offline : réseau coupé).</summary>
private sealed class FakeServer
{
public byte[]? Keyring { get; set; }
public byte[]? Maintenance { get; set; }
public bool Offline { get; set; }
public int MaintenanceRequests { get; private set; }
public Task<byte[]> Download(Uri uri, long max, CancellationToken ct)
{
if (Offline) throw new HttpRequestException("hors ligne");
if (uri == KeyringUrl) return Keyring is { } k ? Task.FromResult(k) : throw new HttpRequestException("404", null, HttpStatusCode.NotFound);
MaintenanceRequests++;
if (Maintenance is not { } bytes) throw new HttpRequestException("404", null, HttpStatusCode.NotFound);
if (bytes.Length > max) throw new InvalidDataException("réponse trop volumineuse");
return Task.FromResult(bytes);
}
public void Serve(MaintenanceDocument document) => Maintenance = Encoding.UTF8.GetBytes(MaintenanceRules.Serialize(document));
}
private static MaintenanceDocument Signed(ECDsa key, long revision, bool enabled, string? message = null, DateTimeOffset? signedAt = null)
{
var document = new MaintenanceDocument
{
Revision = revision,
Enabled = enabled,
Message = enabled ? message : null,
Since = enabled ? DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeSeconds()) : null
};
return document with
{
Signature = SigningKit.Sign(key, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(document), signedAt)
};
}
private static (MaintenanceService Service, FakeServer Server, List<MaintenanceDocument?> Changes) Create(
string folder, ECDsa root, ECDsa admin, ContentSigningMode mode, params string[] scopes)
{
var server = new FakeServer();
server.Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
SigningKit.Entry(admin, "Nicolas", scopes.Length == 0 ? new[] { SigningScopes.Maintenance } : scopes))));
var trust = mode == ContentSigningMode.Off
? null
: new ContentTrustService(server.Download, folder, KeyringUrl, new[] { SigningKit.Root(root) }, mode);
var service = new MaintenanceService(server.Download, MaintenanceUrl, trust);
var changes = new List<MaintenanceDocument?>();
service.Changed += changes.Add;
return (service, server, changes);
}
[Fact]
public async Task Signed_maintenance_is_applied_then_ended_and_each_change_is_reported_once()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require);
// Rien de publié (404) : pas de maintenance, rien à signaler.
Assert.False(await service.RefreshAsync());
Assert.Empty(changes);
var now = DateTimeOffset.UtcNow;
server.Serve(Signed(admin, 1, true, "Retour vers 18 h.", now.AddMinutes(-3)));
Assert.True(await service.RefreshAsync());
Assert.True(service.IsActive);
Assert.Equal("Retour vers 18 h.", Assert.Single(changes)!.Message);
// Même fichier : rien de nouveau.
Assert.True(await service.RefreshAsync());
Assert.Single(changes);
// Nouveau message : signalé.
server.Serve(Signed(admin, 2, true, "Retour vers 19 h.", now.AddMinutes(-2)));
await service.RefreshAsync();
Assert.Equal(2, changes.Count);
Assert.Equal("Retour vers 19 h.", changes[^1]!.Message);
// Coupure réseau pendant la maintenance : dernier état vérifié conservé.
server.Offline = true;
Assert.True(await service.RefreshAsync());
Assert.Equal(2, changes.Count);
server.Offline = false;
// Fin de la maintenance.
server.Serve(Signed(admin, 3, false, signedAt: now.AddMinutes(-1)));
Assert.False(await service.RefreshAsync());
Assert.False(service.IsActive);
Assert.Equal(3, changes.Count);
Assert.False(changes[^1]!.Enabled);
}
[Fact]
public async Task In_require_mode_forged_unsigned_or_replayed_maintenance_is_ignored()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
using var attacker = SigningKit.NewKey();
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require);
var now = DateTimeOffset.UtcNow;
// Non signé, signé par une clé absente du trousseau, ou contenu modifié après signature : ignorés.
var unsigned = Signed(admin, 1, true) with { Signature = null };
var forged = Signed(attacker, 1, true);
var tampered = Signed(admin, 1, true, "Message d'origine") with { Message = "Message modifié" };
foreach (var document in new[] { unsigned, forged, tampered })
{
server.Serve(document);
Assert.False(await service.RefreshAsync());
}
Assert.Empty(changes);
// Légitime : maintenance, puis fin (signée plus tard).
var old = Signed(admin, 1, true, signedAt: now.AddMinutes(-10));
server.Serve(old);
Assert.True(await service.RefreshAsync());
server.Serve(Signed(admin, 2, false, signedAt: now.AddMinutes(-5)));
Assert.False(await service.RefreshAsync());
// Un serveur compromis ressert l'ancienne maintenance légitime : refusée (révision et date de signature).
server.Serve(old);
Assert.False(await service.RefreshAsync());
// Même après un redémarrage du launcher (date de la dernière signature acceptée gardée dans l'état des signatures).
var (restarted, restartedServer, _) = Create(folder.Path, root, admin, ContentSigningMode.Require);
restartedServer.Serve(old);
Assert.False(await restarted.RefreshAsync());
// Hors limites, même bien signé : ignoré.
var linked = new MaintenanceDocument { Revision = 3, Enabled = true, Message = "Infos : discord.gg/x", Since = old.Since };
server.Serve(linked with
{
Signature = SigningKit.Sign(admin, ContentKinds.Maintenance, ContentKinds.MaintenanceId, MaintenanceRules.Digest(linked))
});
Assert.False(await service.RefreshAsync());
}
[Fact]
public async Task A_key_without_the_maintenance_scope_cannot_start_a_maintenance()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
var (service, server, changes) = Create(folder.Path, root, admin, ContentSigningMode.Require, SigningScopes.Catalog, SigningScopes.Releases);
server.Serve(Signed(admin, 1, true));
Assert.False(await service.RefreshAsync());
Assert.Empty(changes);
}
/// <summary>Horloge avancée à la main (trousseau).</summary>
private sealed class ManualClock : TimeProvider
{
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => Now;
}
[Fact]
public async Task A_maintenance_published_after_the_keyring_gained_the_scope_is_applied_without_restarting()
{
// Cas signalé en 3.3.0 : launcher ouvert, trousseau republié avec l'autorisation « maintenance » (étape 3 decies),
// puis maintenance activée. Le launcher gardait l'ancien trousseau (clé connue, sans l'autorisation) jusqu'au
// redémarrage : chaque relecture refusait la maintenance.
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
var server = new FakeServer
{
Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog))))
};
var clock = new ManualClock();
var trust = new ContentTrustService(server.Download, folder.Path, KeyringUrl, new[] { SigningKit.Root(root) },
ContentSigningMode.Require, time: clock);
var service = new MaintenanceService(server.Download, MaintenanceUrl, trust, clock);
var changes = new List<MaintenanceDocument?>();
service.Changed += changes.Add;
// Démarrage : rien de publié, trousseau n° 1 en mémoire.
Assert.NotNull(await trust.GetKeyringAsync());
Assert.False(await service.RefreshAsync());
// Trousseau n° 2 (autorisation « maintenance » ajoutée), puis maintenance activée quelques minutes plus tard.
server.Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 2,
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog, SigningScopes.Maintenance))));
clock.Now += TimeSpan.FromMinutes(3);
server.Serve(Signed(admin, 1, true, "Retour vers 18 h."));
// Relecture périodique suivante : trousseau retéléchargé, maintenance appliquée.
Assert.True(await service.RefreshAsync());
Assert.True(service.IsActive);
Assert.Equal("Retour vers 18 h.", Assert.Single(changes)!.Message);
}
[Fact]
public async Task Offline_at_startup_or_without_address_there_is_no_maintenance_and_warn_mode_applies_it()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
// Hors ligne au démarrage : jamais de maintenance (le jeu installé reste jouable).
var (offline, offlineServer, _) = Create(folder.Path, root, admin, ContentSigningMode.Require);
offlineServer.Offline = true;
Assert.False(await offline.RefreshAsync());
Assert.Null(offline.Current);
// Sans adresse (ni trousseau ni catalogue) : rien n'est lu.
Assert.Null(MaintenanceService.PublishedUrl(null, null));
Assert.Equal(MaintenanceUrl, MaintenanceService.PublishedUrl(KeyringUrl.AbsoluteUri, null));
var none = new MaintenanceService((_, _, _) => throw new InvalidOperationException("aucune requête attendue"), null, null);
Assert.False(await none.RefreshAsync());
// « warn » : document non signé appliqué (signalé dans le journal) ; « off » : appliqué sans vérification.
foreach (var mode in new[] { ContentSigningMode.Warn, ContentSigningMode.Off })
{
using var modeFolder = new TempFolder();
var (service, server, changes) = Create(modeFolder.Path, root, admin, mode);
server.Serve(Signed(admin, 1, true) with { Signature = null });
Assert.True(await service.RefreshAsync());
Assert.Single(changes);
}
// Fichier trop volumineux : ignoré.
var (big, bigServer, bigChanges) = Create(folder.Path, root, admin, ContentSigningMode.Off);
bigServer.Maintenance = new byte[MaintenanceRules.MaxBytes + 1];
Assert.False(await big.RefreshAsync());
Assert.Empty(bigChanges);
}
}
@@ -0,0 +1,320 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
using GameLauncher.Website.Services;
using Microsoft.Extensions.Logging.Abstractions;
namespace GameLauncher.Tests.Website;
/// <summary>
/// Site vitrine : lecture des fichiers du serveur du launcher (signature, adresses, bornes) et liseuse
/// Markdown des actualités (aucun HTML ni lien dangereux ne doit passer).
/// </summary>
public sealed class WebsiteRemoteContentTests : IDisposable
{
private static readonly Uri UpdateUrl = new("https://launcher.example/launcher/launcher-update.json");
private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
private readonly IReadOnlyList<UpdateSignature.TrustedKey> _trusted;
public WebsiteRemoteContentTests()
{
_trusted = UpdateSignature.ParsePublicKeys(_key.ExportSubjectPublicKeyInfoPem());
}
public void Dispose() => _key.Dispose();
private static LauncherRelease Release(string file = "GameLauncher-3.4.0.exe", string product = "EZRLauncher") => new()
{
Product = product,
Version = "3.4.0",
File = file,
Size = 1234,
Sha256 = new string('A', 64),
PublishedAt = new DateTimeOffset(2026, 11, 10, 12, 0, 0, TimeSpan.Zero),
PriorityVersion = "3.4.0",
Changelog = new()
{
new ChangelogEntry
{
Version = "3.4.0",
Title = "Launcher 3.4.0",
Sections = new()
{
new ChangelogSection { Title = "EN BREF", Items = { new ChangelogItem { Text = "Nouveauté" } } },
new ChangelogSection { Title = "DÉVELOPPEMENT", Items = { new ChangelogItem { Text = "Interne" } } },
new ChangelogSection { Title = "Serveur", Items = { new ChangelogItem { Text = "Rien" } } }
}
},
new ChangelogEntry { Version = "3.3.1", Title = "Ancienne" }
}
};
private byte[] Envelope(LauncherRelease release, ECDsa? key = null) =>
Encoding.UTF8.GetBytes(UpdateSignature.CreateEnvelope(JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options), key ?? _key));
private static readonly string[] Hidden = { "Serveur", "Developpement" };
[Fact]
public void Launcher_signature_valide_donne_la_version_l_adresse_et_les_notes_filtrees()
{
var snapshot = RemoteContentReader.ReadLauncher(Envelope(Release()), UpdateUrl, _trusted, "EZRLauncher", Hidden, DateTimeOffset.UtcNow);
Assert.Equal("3.4.0", snapshot.Version);
Assert.Equal("https://launcher.example/launcher/GameLauncher-3.4.0.exe", snapshot.DownloadUrl.ToString());
Assert.Equal("GameLauncher-3.4.0.exe", snapshot.FileName);
Assert.Equal(new string('a', 64), snapshot.Sha256);
Assert.True(snapshot.IsPriority);
Assert.NotNull(snapshot.LatestNotes);
Assert.Equal("Launcher 3.4.0", snapshot.LatestNotes!.Title);
// Sections réservées à l'équipe retirées, sans tenir compte des accents ni de la casse.
Assert.Equal(new[] { "EN BREF" }, snapshot.LatestNotes.Sections.Select(s => s.Title).ToArray());
}
[Fact]
public void Launcher_contenu_modifie_est_refuse()
{
var json = Encoding.UTF8.GetString(Envelope(Release()));
var envelope = JsonSerializer.Deserialize<SignedEnvelope>(json, JsonDefaults.Options)!;
var payload = Encoding.UTF8.GetString(Convert.FromBase64String(envelope.Payload)).Replace("GameLauncher-3.4.0.exe", "Piege-3.4.0.exe");
envelope.Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
var tampered = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonDefaults.Options);
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(tampered, UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_signe_par_une_autre_cle_est_refuse()
{
using var other = ECDsa.Create(ECCurve.NamedCurves.nistP256);
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(), other), UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Theory]
[InlineData("https://ailleurs.example/GameLauncher.exe")]
[InlineData("http://launcher.example/launcher/GameLauncher.exe")]
[InlineData("https://launcher.example:8443/launcher/GameLauncher.exe")]
[InlineData("GameLauncher.exe?x=1")]
[InlineData("notes.txt")]
public void Launcher_executable_hors_du_serveur_ou_inattendu_est_refuse(string file)
{
// Même signé, un fichier ne peut pas envoyer les visiteurs vers un autre serveur.
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(file)), UpdateUrl, _trusted, null, Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_produit_inattendu_est_refuse()
{
Assert.Throws<RemoteContentException>(() =>
RemoteContentReader.ReadLauncher(Envelope(Release(product: "AutreLauncher")), UpdateUrl, _trusted, "EZRLauncher", Hidden, DateTimeOffset.UtcNow));
}
[Fact]
public void Launcher_cle_publique_integree_au_site()
{
var keys = RemoteContentReader.LoadEmbeddedKeys();
Assert.NotEmpty(keys);
Assert.All(keys, k => Assert.Equal(16, k.KeyId.Length));
}
private static byte[] Manifest(string gameId, int entries, string extraChangelogText = "Texte")
{
var manifest = new
{
formatVersion = 1,
gameId,
version = "1.8",
releaseDate = new DateTimeOffset(2026, 11, 1, 18, 0, 0, TimeSpan.FromHours(1)),
archive = new { url = "jeu.zip", size = 1000L, sha256 = new string('b', 64) },
files = new[] { new { path = "a.bin", size = 600L }, new { path = "b.bin", size = 400L } },
changelog = Enumerable.Range(0, entries).Select(i => new
{
version = $"1.{entries - i}",
title = "Version‮ " + i,
sections = new[] { new { title = "NOUVEAU", items = new[] { new { text = extraChangelogText } } } }
})
};
return JsonSerializer.SerializeToUtf8Bytes(manifest);
}
[Fact]
public void Jeu_manifest_lu_borne_et_nettoye_comme_dans_le_launcher()
{
var snapshot = RemoteContentReader.ReadGame(Manifest("EZR", 50, new string('x', 5000)), "EZR", DateTimeOffset.UtcNow);
Assert.Equal("1.8", snapshot.Version);
Assert.Equal(1000, snapshot.DownloadSize);
Assert.Equal(1000, snapshot.InstalledSize);
Assert.Equal(ChangelogLimits.MaxEntries, snapshot.Changelog.Count);
Assert.DoesNotContain('‮', snapshot.Changelog[0].Title!);
Assert.True(snapshot.Changelog[0].Sections[0].Items[0].Text.Length <= ChangelogLimits.MaxTextLength);
}
[Fact]
public void Jeu_manifest_d_un_autre_jeu_est_refuse()
{
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Manifest("AUTRE", 1), "EZR", DateTimeOffset.UtcNow));
}
[Fact]
public void Jeu_manifest_illisible_est_refuse()
{
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Encoding.UTF8.GetBytes("{ pas du json"), "EZR", DateTimeOffset.UtcNow));
Assert.Throws<RemoteContentException>(() => RemoteContentReader.ReadGame(Encoding.UTF8.GetBytes("{\"gameId\":\"EZR\",\"version\":\"abc\"}"), "EZR", DateTimeOffset.UtcNow));
}
[Theory]
[InlineData("https://launcher.example/manifest.json", false, true)]
[InlineData("http://launcher.example/manifest.json", true, false)]
[InlineData("http://127.0.0.1:8099/manifest.json", false, false)]
[InlineData("http://127.0.0.1:8099/manifest.json", true, true)]
[InlineData("https://user:secret@launcher.example/manifest.json", false, false)]
[InlineData("file:///etc/passwd", true, false)]
[InlineData("pas une adresse", false, false)]
public void Adresses_lues_https_seulement(string url, bool allowLoopbackHttp, bool expected)
{
Assert.Equal(expected, RemoteFetcher.IsAllowedUrl(url, allowLoopbackHttp, out _));
}
[Fact]
public void Erreur_reseau_detaillee_certificat_refuse()
{
var ex = new HttpRequestException("The SSL connection could not be established, see inner exception.",
new System.Security.Authentication.AuthenticationException("The remote certificate is invalid: PartialChain"));
var text = RemoteFetcher.Describe(ex);
Assert.StartsWith("certificat HTTPS refusé", text);
Assert.Contains("PartialChain", text);
Assert.StartsWith("serveur injoignable", RemoteFetcher.Describe(new HttpRequestException("Name or service not known")));
}
[Fact]
public async Task Lecture_bornee_au_dela_de_la_limite()
{
using var small = new MemoryStream(new byte[100]);
Assert.NotNull(await RemoteFetcher.ReadBoundedAsync(small, 100, CancellationToken.None));
using var big = new MemoryStream(new byte[101]);
Assert.Null(await RemoteFetcher.ReadBoundedAsync(big, 100, CancellationToken.None));
}
}
public sealed class WebsiteMarkdownTests
{
[Fact]
public void Html_brut_affiche_jamais_interprete()
{
var html = SafeMarkdown.ToHtml("Bonjour <script>alert(1)</script> <img src=x onerror=alert(1)>");
Assert.DoesNotContain("<script", html);
Assert.DoesNotContain("<img", html);
Assert.Contains("&lt;script&gt;", html);
}
[Theory]
[InlineData("[clic](javascript:alert(1))")]
[InlineData("[clic](JaVaScRiPt:alert(1))")]
[InlineData("[clic](data:text/html;base64,PHNjcmlwdD4=)")]
[InlineData("[clic](//evil.example/x)")]
[InlineData("[clic](vbscript:msgbox)")]
public void Liens_dangereux_retires(string markdown)
{
var html = SafeMarkdown.ToHtml(markdown);
Assert.DoesNotContain("href", html);
Assert.Contains("clic", html);
}
[Fact]
public void Lien_externe_sans_acces_a_la_page_d_origine()
{
var html = SafeMarkdown.ToHtml("Voir [le site](https://example.org/page).");
Assert.Contains("<a href=\"https://example.org/page\" rel=\"noopener noreferrer\">le site</a>", html);
var local = SafeMarkdown.ToHtml("[Notes](/jeux/exovival-zombie-reborn/notes-de-version)");
Assert.Contains("<a href=\"/jeux/exovival-zombie-reborn/notes-de-version\">Notes</a>", local);
}
[Fact]
public void Attribut_impossible_a_injecter_dans_un_lien()
{
var html = SafeMarkdown.ToHtml("[x](/a\"onmouseover=alert(1))");
Assert.DoesNotContain("\"onmouseover", html);
}
[Theory]
[InlineData("![photo](https://evil.example/x.png)")]
[InlineData("![photo](/img/../appsettings.json)")]
[InlineData("![photo](/uploads/x.png)")]
public void Images_hors_du_site_refusees(string markdown)
{
Assert.DoesNotContain("<img", SafeMarkdown.ToHtml(markdown));
}
[Fact]
public void Syntaxe_prise_en_charge()
{
var html = SafeMarkdown.ToHtml("# Titre\n\nUn **gras**, un *italique* et `du code`.\n\n- un\n- deux\n - sous-point\n\n1. premier\n2. second\n\n> citation\n\n---\n\n![Le gymnase](/img/exovival/vhs-gymnase.webp)");
Assert.Contains("<h2>Titre</h2>", html);
Assert.Contains("<strong>gras</strong>", html);
Assert.Contains("<em>italique</em>", html);
Assert.Contains("<code>du code</code>", html);
Assert.Contains("<ul><li>un</li><li>deux<ul><li>sous-point</li></ul></li></ul>", html);
Assert.Contains("<ol><li>premier</li><li>second</li></ol>", html);
Assert.Contains("<blockquote><p>citation</p></blockquote>", html);
Assert.Contains("<hr>", html);
Assert.Contains("<img src=\"/img/exovival/vhs-gymnase.webp\" alt=\"Le gymnase\"", html);
}
}
public sealed class WebsiteNewsTests
{
[Fact]
public void Article_lu_avec_son_en_tete()
{
var article = NewsService.Parse("mon-article", "---\ntitle: \"Titre : avec deux-points\"\ndate: 2026-11-10\nsummary: Résumé\ntheme: exovival\nimage: /img/exovival/couloir.webp\n---\nCorps **gras**.");
Assert.NotNull(article);
Assert.Equal("Titre : avec deux-points", article!.Title);
Assert.Equal(new DateOnly(2026, 11, 10), article.Date);
Assert.Equal("exovival", article.Theme);
Assert.Equal("/img/exovival/couloir.webp", article.Image);
Assert.Contains("<strong>gras</strong>", article.Html);
}
[Theory]
[InlineData("Mon_Article")]
[InlineData("../secret")]
[InlineData("")]
public void Nom_de_fichier_incorrect_ignore(string slug)
{
Assert.Null(NewsService.Parse(slug, "---\ntitle: T\ndate: 2026-11-10\n---\nx"));
}
[Fact]
public void En_tete_incomplet_ignore_et_valeurs_inconnues_neutralisees()
{
Assert.Null(NewsService.Parse("a", "---\ntitle: T\n---\nx"));
Assert.Null(NewsService.Parse("a", "title: T\ndate: 2026-11-10\nx"));
var article = NewsService.Parse("a", "---\ntitle: T\ndate: 2026-11-10\ntheme: <script>\nimage: https://evil.example/x.png\n---\nx");
Assert.Equal("launcher", article!.Theme);
Assert.Null(article.Image);
}
[Fact]
public void Brouillons_masques_hors_developpement()
{
var folder = Directory.CreateTempSubdirectory("news-tests").FullName;
try
{
File.WriteAllText(Path.Combine(folder, "publie.md"), "---\ntitle: A\ndate: 2026-11-10\n---\nx");
File.WriteAllText(Path.Combine(folder, "brouillon.md"), "---\ntitle: B\ndate: 2026-11-11\ndraft: true\n---\nx");
Assert.Single(new NewsService(folder, includeDrafts: false, NullLogger.Instance).All);
Assert.Equal(2, new NewsService(folder, includeDrafts: true, NullLogger.Instance).All.Count);
Assert.Null(new NewsService(folder, includeDrafts: false, NullLogger.Instance).Find("../publie"));
}
finally
{
Directory.Delete(folder, true);
}
}
}
+68 -11
View File
@@ -20,6 +20,7 @@ namespace LauncherPublisher;
/// --config src\GameLauncher\launcher.json --output "D:\Publish\launcher"
/// [--version 1.2.0] [--notes notes-launcher.txt]
/// [--public-key src\GameLauncher\Security\update-signing.pub]
/// [--priority | --clear-priority] [--yes]
/// LauncherPublisher verify --manifest "D:\Publish\launcher\launcher-update.json"
/// --public-key src\GameLauncher\Security\update-signing.pub [--exe fichier.exe]
///
@@ -49,6 +50,11 @@ public static class Program
default: return Usage();
}
}
catch (OperationCanceledException ex)
{
Warn(ex.Message);
return 1;
}
catch (Exception ex)
{
Error($"Erreur : {ex.Message}");
@@ -136,6 +142,22 @@ public static class Program
@"Augmentez <Version> dans src\GameLauncher\GameLauncher.csproj, recompilez le launcher, puis relancez. (--force pour passer outre)");
}
// Version PRIORITAIRE (correctif critique) : installée au démarrage même chez les joueurs qui ont désactivé la
// mise à jour automatique (leur réglage n'est pas modifié). Confirmation demandée : rien n'est encore écrit.
var markPriority = o.Has("--priority");
var clearPriority = o.Has("--clear-priority");
if (markPriority && clearPriority)
throw new ArgumentException("--priority et --clear-priority sont incompatibles.");
if (markPriority && !o.Has("--yes"))
{
Warn($"Version PRIORITAIRE : le launcher {display} sera installé au démarrage MÊME chez les joueurs qui ont désactivé " +
"la mise à jour automatique (leur réglage n'est pas modifié). À réserver aux correctifs critiques (faille de sécurité…).");
Console.Write("Publier cette version comme prioritaire ? (o/N) ");
var answer = Console.ReadLine()?.Trim().ToLowerInvariant();
if (answer is not ("o" or "oui"))
throw new OperationCanceledException("Abandon : rien n'a été publié.");
}
using var key = LoadPrivateKey(keyPath);
var keyId = UpdateSignature.ComputeKeyId(key.ExportSubjectPublicKeyInfo());
@@ -172,10 +194,20 @@ public static class Program
ChangelogParser.Parse(await File.ReadAllLinesAsync(notesFile), entry);
}
// Historique : notes des versions déjà publiées (launcher-update.json précédent, signé par cette clé)
var history = ReadHistory(previousJson, key, keyId, display);
// launcher-update.json précédent, signé par cette clé : historique des notes et version prioritaire repris
var previousRelease = ReadPreviousRelease(previousJson, key, keyId);
var history = ReadHistory(previousRelease, display);
var changelog = ChangelogLimits.Normalize(new[] { entry }.Concat(history));
// Version prioritaire : celle-ci (--priority), aucune (--clear-priority), sinon celle déjà publiée
var priorityVersion = LauncherRelease.ResolvePriorityVersion(version, markPriority, clearPriority, previousRelease);
if (previousRelease is null && previousJson is not null && !markPriority && !clearPriority &&
TryReadUnverified(previousJson)?.PriorityVersion is { Length: > 0 } unverifiedPriority)
Warn($"Version prioritaire {unverifiedPriority} du launcher-update.json précédent NON reprise (signature non vérifiée) : " +
"relancez avec --priority si cette version doit rester imposée.");
if (clearPriority && previousRelease?.PriorityVersion is { Length: > 0 } removedPriority)
Warn($"Version prioritaire {removedPriority} retirée : seuls les joueurs ayant activé la mise à jour automatique recevront cette version.");
var release = new LauncherRelease
{
Product = product,
@@ -187,7 +219,8 @@ public static class Program
// Format historique, lu par les launchers antérieurs à 1.1.3
Title = entry.Title,
Notes = entry.Changes.ToList(),
Changelog = changelog
Changelog = changelog,
PriorityVersion = priorityVersion
};
release.Validate();
@@ -219,6 +252,7 @@ public static class Program
? " Notes : aucune (--notes non fourni ou fichier vide)"
: $" Notes : « {current.Title ?? "sans titre"} », {current.Sections.Count} section(s), " +
$"{current.Sections.Sum(x => x.Items.Count)} élément(s) ; historique : {release.Changelog.Count - 1} version(s)");
Console.WriteLine($" Priorité : {DescribePriority(release)}");
Console.WriteLine($" Produit : {product}");
Console.WriteLine($" Clé : {keyId}");
Console.WriteLine($" SHA-256 : {sha256}");
@@ -245,6 +279,7 @@ public static class Program
Console.WriteLine($" Version : {release.Version} ({release.PublishedAt:yyyy-MM-dd HH:mm})");
Console.WriteLine($" Fichier : {release.File} ({Format.Bytes(release.Size)})");
Console.WriteLine($" SHA-256 : {release.Sha256}");
Console.WriteLine($" Priorité: {DescribePriority(release)}");
var exe = o.Get("--exe");
if (exe is null) return true;
@@ -295,25 +330,30 @@ public static class Program
}
/// <summary>
/// Notes des versions déjà publiées, reprises du launcher-update.json précédent — uniquement
/// si sa signature est valide pour la clé utilisée (un fichier modifié à la main est ignoré).
/// launcher-update.json précédent — uniquement si sa signature est valide pour la clé utilisée (un fichier
/// modifié à la main est ignoré) : son historique des notes et sa version prioritaire sont repris.
/// </summary>
private static IReadOnlyList<ChangelogEntry> ReadHistory(string? previousJson, ECDsa key, string keyId, string version)
private static LauncherRelease? ReadPreviousRelease(string? previousJson, ECDsa key, string keyId)
{
if (previousJson is null) return Array.Empty<ChangelogEntry>();
LauncherRelease previous;
if (previousJson is null) return null;
try
{
previous = UpdateSignature.ReadRelease(previousJson, new[]
return UpdateSignature.ReadRelease(previousJson, new[]
{
new UpdateSignature.TrustedKey(keyId, key.ExportSubjectPublicKeyInfo())
});
}
catch (UpdateSecurityException ex)
{
Warn($"Historique des notes ignoré ({ex.Message}).");
return Array.Empty<ChangelogEntry>();
Warn($"launcher-update.json précédent ignoré ({ex.Message}) : historique des notes et version prioritaire non repris.");
return null;
}
}
/// <summary>Notes des versions déjà publiées (publication précédente vérifiée), sans la version en cours.</summary>
private static IReadOnlyList<ChangelogEntry> ReadHistory(LauncherRelease? previous, string version)
{
if (previous is null) return Array.Empty<ChangelogEntry>();
// Version publiée avant le format enrichi : son titre et sa liste deviennent une entrée
var entries = previous.Changelog.Count > 0
@@ -322,6 +362,16 @@ public static class Program
return entries.Where(e => !string.Equals(e.Version, version, StringComparison.OrdinalIgnoreCase)).ToList();
}
/// <summary>Ligne « Priorité » affichée après publish et verify.</summary>
private static string DescribePriority(LauncherRelease release)
{
if (!release.TryGetPriorityVersion(out var priority)) return "normale (installée selon le réglage de chaque joueur)";
var display = VersionUtil.Display(priority);
return release.IsPriorityRelease
? "PRIORITAIRE — installée au démarrage même si la mise à jour automatique est désactivée (réglage du joueur conservé)"
: $"normale, version prioritaire {display} reprise — les launchers antérieurs à {display} se mettent à jour quel que soit leur réglage";
}
private static LauncherRelease? TryReadUnverified(string envelopeJson)
{
try
@@ -509,7 +559,14 @@ public static class Program
(--config <launcher.json> | --product <launcherId>)
[--version x.y.z] [--notes <notes.txt>] [--title <texte>]
[--public-key <update-signing.pub>] [--force]
[--priority | --clear-priority] [--yes]
Signe la nouvelle version et prépare le dossier à envoyer sur le serveur.
--priority : version PRIORITAIRE (correctif critique), installée au démarrage même
chez les joueurs qui ont désactivé la mise à jour automatique (leur
réglage n'est pas modifié). Confirmation demandée, sauf --yes.
Reprise par les publications suivantes : un launcher plus ancien que
cette version se met à jour quoi qu'il arrive.
--clear-priority : ne reprend pas la version prioritaire déjà publiée.
verify --manifest <launcher-update.json | https://…> --public-key <update-signing.pub> [--exe <fichier>]
Vérifie la signature (et l'exécutable).
+51 -7
View File
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Globalization;
using System.IO;
using System.Text;
@@ -9,6 +10,7 @@ using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using SkiaSharp;
namespace UiSnapshot;
@@ -54,7 +56,7 @@ internal interface ISceneSet
}
/// <summary>
/// Rendu des fenêtres en PNG et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
/// Rendu des fenêtres en PNG (ou WebP) et diagnostic de leur mise en page. Les fenêtres sont affichées HORS de
/// l'écran (à droite de tous les moniteurs), sans prendre le focus : rien n'apparaît pendant l'exécution.
/// </summary>
internal sealed class Capture
@@ -222,7 +224,7 @@ internal sealed class Capture
root.UpdateLayout();
var image = Render(root);
Save(image, record.Id + ".png", record);
Save(image, record.Id + _options.ImageExtension, record);
record.Size = $"{image.PixelWidth}×{image.PixelHeight}";
Diagnose(root, record);
@@ -267,16 +269,58 @@ internal sealed class Capture
private void Save(BitmapSource image, string fileName, SceneRecord record)
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(image));
using (var stream = File.Create(Path.Combine(OutputDirectory, fileName)))
encoder.Save(stream);
{
if (_options.Format == SnapshotOptions.FormatWebp)
{
SaveWebp(image, stream, _options.Quality);
}
else
{
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(BitmapFrame.Create(image));
encoder.Save(stream);
}
}
record.Files.Add(fileName);
}
/// <summary>
/// WebP (--format webp) : Windows (WIC) sait lire le WebP mais pas l'écrire ; l'encodage passe par
/// SkiaSharp (bibliothèque graphique maintenue par Microsoft, licence MIT, utilisée par cet outil de
/// développement seulement : jamais par le launcher). Les pixels du rendu WPF (BGRA prémultiplié) sont
/// copiés tels quels ; la transparence (--backdrop transparent) est conservée.
/// </summary>
private static void SaveWebp(BitmapSource image, Stream stream, int quality)
{
if (image.Format != PixelFormats.Pbgra32)
image = new FormatConvertedBitmap(image, PixelFormats.Pbgra32, null, 0);
var width = image.PixelWidth;
var height = image.PixelHeight;
var stride = width * 4;
var pixels = new byte[stride * height];
image.CopyPixels(pixels, stride, 0);
var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);
try
{
var info = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul);
using var pixmap = new SKPixmap(info, handle.AddrOfPinnedObject(), stride);
var compression = quality >= 100 ? SKWebpEncoderCompression.Lossless : SKWebpEncoderCompression.Lossy;
using var data = pixmap.Encode(new SKWebpEncoderOptions(compression, quality))
?? throw new InvalidOperationException("Encodage WebP impossible (SkiaSharp).");
data.SaveTo(stream);
}
finally
{
handle.Free();
}
}
/// <summary>
/// Zone qui défile (la plus grande de l'écran) : une capture par « page » supplémentaire
/// (&lt;scène&gt;.p2.png…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
/// (&lt;scène&gt;.p2.png ou .p2.webp…, 4 au plus), puis retour en haut. La dernière capture montre toujours le BAS
/// de la zone (une page très longue n'a pas de pages intermédiaires, mais sa fin est visible).
/// </summary>
private async Task ScrollPagesAsync(FrameworkElement root, SceneRecord record)
@@ -295,7 +339,7 @@ internal sealed class Capture
var target = page == 5 ? scroller.ScrollableHeight : Math.Min(offset, scroller.ScrollableHeight);
scroller.ScrollToVerticalOffset(target);
await SettleAsync(160);
Save(Render(root), $"{record.Id}.p{page}.png", record);
Save(Render(root), $"{record.Id}.p{page}{_options.ImageExtension}", record);
if (target >= scroller.ScrollableHeight) break;
}
scroller.ScrollToVerticalOffset(original);
+274 -3
View File
@@ -7,6 +7,8 @@ using GameLauncher.Contracts;
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;
@@ -18,7 +20,8 @@ namespace UiSnapshot.Fakes;
/// <summary>
/// Launcher API simulée, EN MÉMOIRE (gestionnaire HTTP du client de l'API : aucune connexion réseau).
/// Elle répond avec des données de démonstration cohérentes (administrateurs, appareils, journal,
/// catalogue, notes de version, signatures) et accepte les modifications sans rien enregistrer.
/// catalogue, notes de version, signatures, demandes des joueurs et leurs captures d'écran) et accepte les
/// modifications sans rien enregistrer.
/// Signatures et jetons ne sont pas vérifiés : seul l'affichage compte ici.
/// Une route inconnue (module ajouté depuis) répond 404 et est signalée dans le rapport.
/// </summary>
@@ -163,6 +166,36 @@ internal sealed class FakeApi : HttpMessageHandler
return Json(_world.PrunePreview(gameId));
case ["v1", "games", var gameId, "objects", "prune"]:
return Json(new ObjectsPruneResult { GameId = gameId, Deleted = 412, FreedBytes = 1_873_000_000 });
// ---- 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:
return _world.FindFeedback(gameId, feedbackId) is { } detail
? Json(detail)
: Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Demande introuvable.");
case ["v1", "games", var gameId, "feedback", var feedbackId] when method == HttpMethod.Put:
return _world.FindFeedback(gameId, feedbackId) is { } current
? Json(SampleWorld.FeedbackUpdated(current, Read<FeedbackUpdateRequest>(body)))
: Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Demande introuvable.");
case ["v1", "games", _, "feedback", _] when method == HttpMethod.Delete:
return NoContent();
case ["v1", "games", var gameId, "feedback", var feedbackId, "attachments", var index] when get:
return _world.FeedbackScreenshot(gameId, feedbackId, index) is { } image
? Bytes(image)
: Error(HttpStatusCode.NotFound, ApiErrorCodes.NotFound, "Capture introuvable.");
}
var description = $"{method.Method} {route}";
@@ -198,6 +231,13 @@ internal sealed class FakeApi : HttpMessageHandler
return response;
}
private static HttpResponseMessage Bytes(byte[] content)
{
var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(content) };
response.Content.Headers.ContentType = new MediaTypeHeaderValue(FeedbackUpload.ContentType);
return response;
}
private static HttpResponseMessage NoContent() => new(HttpStatusCode.NoContent) { Content = new ByteArrayContent(Array.Empty<byte>()) };
private static HttpResponseMessage Error(HttpStatusCode status, string code, string message)
@@ -211,7 +251,8 @@ internal sealed class FakeApi : HttpMessageHandler
/// <summary>
/// Monde de démonstration du menu d'administration : quatre administrateurs (propriétaire connecté,
/// administrateur, éditeur en attente d'approbation, compte révoqué), leurs appareils, des invitations,
/// un journal d'audit varié, le catalogue, les notes de version et l'état des signatures.
/// un journal d'audit varié, le catalogue, les notes de version, l'état des signatures et des demandes de
/// joueurs (tous les types, avec liens et captures d'écran).
/// </summary>
internal sealed class SampleWorld
{
@@ -227,6 +268,8 @@ internal sealed class SampleWorld
private readonly byte[] _newDeviceKey;
private readonly ECDsa _root;
private readonly DateTimeOffset _now = DateTimeOffset.Now;
private readonly byte[][] _screenshots;
private List<FeedbackDetail>? _feedback;
/// <param name="ownerPublicKey">Clé publique (SPKI) de la clé « fichier » utilisée pour se connecter.</param>
/// <param name="root">Clé racine de démonstration (signe le trousseau ; sa clé publique est donnée au menu comme clé intégrée).</param>
@@ -239,6 +282,12 @@ internal sealed class SampleWorld
_adminKey = NewPublicKey();
_revokedKey = NewPublicKey();
_newDeviceKey = NewPublicKey();
// Captures dessinées ici, sur le fil de l'interface (WPF), puis nettoyées comme le ferait le serveur.
_screenshots = new[]
{
SampleData.CreateScreenshot(1280, 720, "Inventaire figé"),
SampleData.CreateScreenshot(800, 600, "Erreur : NullReferenceException")
}.Select(png => FeedbackImages.Sanitize(png, out _, out var clean) is null ? clean! : png).ToArray();
}
public IReadOnlyList<LauncherConfig> Games { get; }
@@ -548,7 +597,7 @@ internal sealed class SampleWorld
var game = Games.FirstOrDefault()?.GameId ?? "EZR";
var entries = new List<AuditEntry>();
void Add(double hoursAgo, string action, string result, string? actor, string? target = null, string? details = null,
string ip = "203.0.113.24")
string? ip = "203.0.113.24")
{
entries.Add(new AuditEntry
{
@@ -567,6 +616,11 @@ internal sealed class SampleWorld
Add(0.05, "audit.export", AuditResults.Ok, OwnerName, "fichier CSV", "245 entrée(s) ; du 01/11/2026 au 07/11/2026 ; filtres : catégorie « Connexions »");
Add(0.08, "audit.view", AuditResults.Ok, OwnerName, details: "200 entrée(s) lue(s)");
Add(0.1, "auth.login", AuditResults.Ok, OwnerName, details: "GameLauncher (PC-BUREAU)");
Add(0.5, "feedback.view", AuditResults.Ok, OwnerName, $"{game} FB-7K2M-Q9XD", "fiche consultée");
Add(2, "feedback.attachment", AuditResults.Ok, null, $"{game} FB-7K2M-Q9XD", "capture n° 2 (png, 800 × 600)", ip: null);
Add(2.01, "feedback.submit", AuditResults.Ok, null, $"{game} FB-7K2M-Q9XD", "Bug ; 2 capture(s) d'écran annoncée(s)", ip: null);
Add(9, "feedback.submit", AuditResults.Denied, null, details: "3 demande(s) refusée(s) : limite par adresse atteinte", ip: null);
Add(30, "feedback.update", AuditResults.Ok, "Camille", $"{game} FB-3PQR-8WXT", "Nouvelle → En cours ; note modifiée");
Add(1, "admins.invite", AuditResults.Ok, OwnerName, "Nicolas", "nouvel appareil pour Nicolas");
Add(3, "admins.invite", AuditResults.Ok, OwnerName, "Alex", "Alex (editor)");
Add(5, "auth.enroll", AuditResults.Ok, null, "Léo", "clé en attente d'approbation", "198.51.100.7");
@@ -579,6 +633,8 @@ internal sealed class SampleWorld
Add(72, "keyring.publish", AuditResults.Ok, OwnerName, "trousseau n° 3", "+1 clé (Camille — PC-STUDIO)");
Add(75, "admins.role", AuditResults.Ok, OwnerName, "Camille", "editor → admin");
Add(98, "admins.revoke", AuditResults.Ok, OwnerName, "Sam");
Add(96, "feedback.purge", AuditResults.Ok, null, game, "4 demande(s) supprimée(s) (conservation)", ip: null);
Add(96.1, "audit.purge", AuditResults.Ok, null, "audit-2025-10.jsonl", "mois au-delà de 12 mois supprimé", ip: null);
Add(99, "auth.login", AuditResults.Denied, "Sam", details: "clé révoquée", ip: "192.0.2.55");
Add(120, "admins.approve_key", AuditResults.Ok, OwnerName, "Camille", "PC-STUDIO");
Add(121, "changelog.edit", AuditResults.Failed, "Camille", $"{game} 1.4.1", "le manifest a changé entre-temps (409)");
@@ -660,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,
@@ -672,4 +764,183 @@ internal sealed class SampleWorld
};
private static string Random(int bytes) => Base64Url.Encode(RandomNumberGenerator.GetBytes(bytes));
// ------------------------------------------------------------------ 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,
Games = Games.Select(g =>
{
var requests = FeedbackRequests().Where(r => SameGame(r, g.GameId)).ToList();
return new FeedbackGameCounts
{
GameId = g.GameId,
New = requests.Count(r => r.Summary.Status == FeedbackStatuses.New),
Open = requests.Count(r => !FeedbackStatuses.IsClosed(r.Summary.Status)),
Total = requests.Count
};
}).ToList(),
RetentionClosedMonths = 12,
RetentionMaxMonths = 24
};
/// <summary>Liste filtrée comme le serveur (« open », « closed », un statut, ou tout).</summary>
public FeedbackPage FeedbackList(string gameId, string? kind, string? status)
{
var items = FeedbackRequests()
.Where(r => SameGame(r, gameId))
.Where(r => string.IsNullOrEmpty(kind) || r.Summary.Kind == kind)
.Where(r => status switch
{
null or "" => true,
"open" => !FeedbackStatuses.IsClosed(r.Summary.Status),
"closed" => FeedbackStatuses.IsClosed(r.Summary.Status),
_ => r.Summary.Status == status
})
.OrderByDescending(r => r.Summary.ReceivedAt)
.Select(r => r.Summary)
.ToList();
return new FeedbackPage { Items = items, NextCursor = null, Total = items.Count };
}
public FeedbackDetail? FindFeedback(string gameId, string id) =>
FeedbackRequests().FirstOrDefault(r => SameGame(r, gameId) && r.Summary.Id == id);
public byte[]? FeedbackScreenshot(string gameId, string id, string index) =>
FindFeedback(gameId, id) is { } detail && int.TryParse(index, out var n) && detail.Attachments.Any(a => a.Index == n)
? _screenshots[(n - 1) % _screenshots.Length]
: null;
/// <summary>Traitement accepté (statut, note), rien n'est enregistré.</summary>
public static FeedbackDetail FeedbackUpdated(FeedbackDetail current, FeedbackUpdateRequest request) => current with
{
Summary = current.Summary with
{
Status = request.Status,
StatusChangedAt = DateTimeOffset.Now,
HasNote = !string.IsNullOrEmpty(request.AdminNote),
Revision = current.Summary.Revision + 1
},
AdminNote = request.AdminNote,
StatusChangedBy = OwnerName
};
private static bool SameGame(FeedbackDetail request, string gameId) =>
string.Equals(request.Summary.GameId, gameId, StringComparison.OrdinalIgnoreCase);
private List<FeedbackDetail> FeedbackRequests() => _feedback ??= CreateFeedback();
/// <summary>
/// Demandes de démonstration : tous les types et statuts, un titre très long, des liens (déguisé, « www »), des
/// captures d'écran, des informations techniques et une note interne.
/// </summary>
private List<FeedbackDetail> CreateFeedback()
{
var list = new List<FeedbackDetail>();
var tech = new FeedbackTechInfo { LauncherVersion = "3.0.0", GameVersion = "1.4.2", Os = "Windows 11 64 bits (10.0.26100)", Language = "fr-FR" };
var game = Games.FirstOrDefault()?.GameId ?? "EZR";
void Add(string gameId, string kind, string title, string status, double hoursAgo, Dictionary<string, string> fields,
bool withTech = false, int attachments = 0, string? note = null, string? reference = null)
{
var seed = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"feedback {gameId} {list.Count}"));
reference ??= "FB-" + new string(seed.Take(4).Select(b => FeedbackRules.ReferenceAlphabet[b % 32]).ToArray()) + "-" +
new string(seed.Skip(4).Take(4).Select(b => FeedbackRules.ReferenceAlphabet[b % 32]).ToArray());
var closed = FeedbackStatuses.IsClosed(status) || status == FeedbackStatuses.InProgress;
var 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
};
var infos = Enumerable.Range(1, attachments).Select(n => // numérotées à partir de 1, comme sur le serveur
{
var image = _screenshots[(n - 1) % _screenshots.Length];
FeedbackImages.Sanitize(image, out var info, out _);
return new FeedbackAttachmentInfo
{
Index = n, Format = info?.Format ?? "png", Width = info?.Width ?? 0, Height = info?.Height ?? 0, Bytes = image.Length
};
}).ToList();
list.Add(new FeedbackDetail
{
Summary = new FeedbackSummary
{
Id = SampleData.Hex(seed)[..32],
Reference = reference,
GameId = gameId,
Kind = kind,
Title = title,
Status = status,
ReceivedAt = _now.AddHours(-hoursAgo),
StatusChangedAt = closed ? _now.AddHours(-hoursAgo / 2) : null,
Badge = badge,
HasNote = note is not null,
HasLinks = FeedbackLinks.Contains(title + "\n" + string.Join("\n", fields.Values)),
AttachmentCount = attachments,
Revision = closed ? 2 : 1
},
FormVersion = FeedbackForms.CurrentVersion,
Fields = fields,
Tech = withTech ? tech : null,
Attachments = infos,
AdminNote = note,
StatusChangedBy = closed ? "Camille" : null
});
}
Add(game, FeedbackKinds.Bug, "Le jeu se fige à l'ouverture de l'inventaire", FeedbackStatuses.New, 2, new()
{
["category"] = "gameplay", ["severity"] = "blocking", ["frequency"] = "often",
["steps"] = "1. Je lance une partie dans la cour de l'école.\n2. Je ramasse la clé du gymnase.\n3. J'ouvre l'inventaire (touche I).",
["expected"] = "L'inventaire s'ouvre et la partie continue.",
["actual"] = "L'image se fige, le son continue, il faut fermer le jeu.\n" +
"Un joueur sur Discord conseille d'installer un correctif : hxxps://patch-exovival[.]ru/fix.exe"
}, withTech: true, attachments: 2, reference: "FB-7K2M-Q9XD");
Add(game, FeedbackKinds.Review, "Une ambiance vraiment réussie", FeedbackStatuses.New, 5, new()
{
["rating"] = "4", ["topic"] = "overall",
["liked"] = "L'ambiance sonore et la lumière dans les couloirs de l'école. On sent vraiment la tension.",
["improve"] = "Les munitions sont un peu trop rares au début.", ["recommend"] = "yes"
});
Add(game, FeedbackKinds.Feature, "Pouvoir réassigner les touches du clavier", FeedbackStatuses.InProgress, 30, new()
{
["area"] = "accessibility", ["idea"] = "Un écran pour changer les touches (je joue en AZERTY et avec une seule main).",
["problem"] = "Certaines actions sont impossibles à faire rapidement.", ["importance"] = "important"
}, note: "Prévu pour la 1.6 (menu Options).", reference: "FB-3PQR-8WXT");
Add(game, FeedbackKinds.Other, "Un serveur Discord officiel ?", FeedbackStatuses.New, 50, new()
{
["message"] = "Est-ce qu'il y aura un serveur Discord officiel ? J'ai vu www.exovival-fans.com mais je ne sais pas si c'est vous."
});
Add(game, FeedbackKinds.Review, "Trop difficile dès le premier niveau, impossible de traverser la cour de l'école sans se faire rattraper",
FeedbackStatuses.New, 70, new() { ["rating"] = "2", ["topic"] = "gameplay", ["improve"] = "Un mode facile serait le bienvenu." });
Add(game, FeedbackKinds.Bug, "Le son grésille dans le gymnase", FeedbackStatuses.Done, 200, new()
{
["category"] = "sound", ["severity"] = "annoying", ["frequency"] = "always",
["steps"] = "Entrer dans le gymnase.", ["actual"] = "Grésillement continu."
}, withTech: true, note: "Corrigé en 1.4.2.");
foreach (var other in Games.Skip(1))
Add(other.GameId, FeedbackKinds.Feature, "Un mode coopératif", FeedbackStatuses.New, 12, new()
{
["area"] = "content", ["idea"] = "Jouer à deux en écran partagé.", ["importance"] = "useful"
});
return list;
}
}
+125
View File
@@ -1,9 +1,15 @@
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Windows;
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;
@@ -253,6 +259,125 @@ 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>
/// Fausse capture d'écran du jeu (PNG dessiné par WPF : ciel, bâtiments, encart de texte), pour la fenêtre « Aide »
/// et la page « Demandes des joueurs ». À créer sur le fil de l'interface (WPF).
/// </summary>
public static byte[] CreateScreenshot(int width, int height, string caption)
{
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
var sky = new LinearGradientBrush(Color.FromRgb(0x1B, 0x26, 0x3B), Color.FromRgb(0x5B, 0x6E, 0x8C), 90);
dc.DrawRectangle(sky, null, new Rect(0, 0, width, height));
var ground = height * 0.7;
var random = new Random(width * 31 + height);
var building = new SolidColorBrush(Color.FromRgb(0x14, 0x1C, 0x2B));
var window = new SolidColorBrush(Color.FromRgb(0xE8, 0xB0, 0x4A));
for (var x = -20.0; x < width; x += width / 14.0)
{
var h = height * (0.15 + random.NextDouble() * 0.3);
dc.DrawRectangle(building, null, new Rect(x, ground - h, width / 16.0, h));
if (random.Next(3) == 0) dc.DrawRectangle(window, null, new Rect(x + width / 64.0, ground - h * 0.7, width / 80.0, height / 40.0));
}
dc.DrawRectangle(new SolidColorBrush(Color.FromRgb(0x2D, 0x3A, 0x2E)), null, new Rect(0, ground, width, height - ground));
var text = new FormattedText(caption, CultureInfo.GetCultureInfo("fr-FR"), FlowDirection.LeftToRight,
new Typeface("Segoe UI"), Math.Max(12, height / 22.0), Brushes.White, 1.0);
var box = new Rect(height / 20.0, height / 20.0, text.Width + height / 10.0, text.Height + height / 20.0);
dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromArgb(0xB0, 0, 0, 0)), null, box, 8, 8);
dc.DrawText(text, new Point(box.X + height / 20.0, box.Y + height / 40.0));
}
var bitmap = new RenderTargetBitmap(width, height, 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 stream.ToArray();
}
/// <summary>Empreinte SHA-256 stable d'un texte (condensés et empreintes de démonstration).</summary>
public static string Digest(string seed) => Hex(SHA256.HashData(Encoding.UTF8.GetBytes(seed)));
}
+54 -8
View File
@@ -1,6 +1,6 @@
# UiSnapshot — captures de toutes les fenêtres du launcher
Outil de développement : il rend **chaque écran du launcher en PNG**, avec des données de démonstration,
Outil de développement : il rend **chaque écran du launcher en PNG** (ou en WebP, `--format webp`), avec des données de démonstration,
et signale les problèmes de mise en page. Il sert à vérifier un réglage graphique (WPF) sans cliquer
partout, et à montrer l'interface à quelqu'un qui ne peut pas lancer le launcher (Claude, par exemple).
@@ -8,6 +8,7 @@ partout, et à montrer l'interface à quelqu'un qui ne peut pas lancer le launch
tools\ui-snapshot.bat toutes les scènes, thème de chaque jeu
tools\ui-snapshot.bat --filter "main-*" seulement la fenêtre principale
tools\ui-snapshot.bat --filter "admin-page-*" --theme all --scale 2
tools\ui-snapshot.bat --filter "main-*" --format webp captures pour le site vitrine (WebP)
tools\ui-snapshot.bat --list liste des scènes
tools\ui-snapshot.bat --help toutes les options
```
@@ -19,28 +20,64 @@ arguments éventuels dans Propriétés › Déboguer.
| Fichier | Contenu |
|---|---|
| `<scène>.png` | La fenêtre telle qu'affichée (cadre, coins arrondis, ombre) sur un fond neutre |
| `<scène>.p2.png`… | Pages suivantes quand une zone défile (jusqu'à 4) |
| `<scène>.png` (ou `.webp`) | La fenêtre telle qu'affichée (cadre, coins arrondis, ombre) sur un fond neutre |
| `<scène>.p2.png`… (ou `.p2.webp`) | Pages suivantes quand une zone défile (jusqu'à 4) |
| `report.md` | **À lire en premier** : texte coupé ou tronqué, éléments qui dépassent de la fenêtre, images sans source, erreurs de liaison (`{Binding}` vers une propriété inexistante), messages que l'écran a tenté d'afficher, routes de l'API non simulées |
| `index.html` | Planche de toutes les captures (clic : taille réelle) |
| `<scène>.tree.txt` | Avec `--tree` : arbre des éléments (type, `x:Name`, position, texte) pour retrouver un élément dans le XAML |
| `launcher.log` | Journal du launcher pendant les captures |
Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les pixels du PNG).
Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les pixels des images).
## Captures pour le site vitrine (WebP)
Le site (`website\GameLauncher.Website\wwwroot\img\launcher\`) utilise des images **WebP** : plus légères que le PNG
à qualité égale. `--format webp` les produit directement :
```
tools\ui-snapshot.bat --filter "main-*,launcher-update-verified" --format webp --quality 85
```
* `--quality` : 1 à 100 (défaut 85, très bon compromis pour des captures d'interface) ; **100 = sans perte**.
Ignorée en PNG, toujours sans perte.
* `--backdrop transparent` : la transparence (coins arrondis, ombre) est conservée en WebP.
* `--scale 1` (défaut) donne la taille de la fenêtre (environ 1200 px de large), adaptée au site ; `--scale 2`
double la taille et le poids.
* Copier ensuite les fichiers voulus de `artifacts\ui-snapshots` dans `wwwroot\img\launcher\` sous les noms
utilisés par les pages (`apercu-exovival.webp`, `apercu-menu.webp`, `apercu-telechargement.webp`,
`apercu-multijeux.webp`, `mise-a-jour-verifiee.webp`), puis republier le site.
* Encodage par **SkiaSharp** (paquet NuGet maintenu par Microsoft, licence MIT, restauré automatiquement par
Visual Studio) : Windows sait lire le WebP mais pas l'écrire. Seul cet outil l'utilise.
## Scènes
* **Fenêtre principale** (`main-*`) : non installé, à jour, mise à jour disponible, téléchargement en
cours, préparation, installation introuvable, réparation nécessaire, jeu lancé, hors ligne, mise à jour
bloquée par sécurité, démarrage ; `main-multi` : plusieurs jeux (exemple `launcher.multi-jeux.example.json`).
bloquée par sécurité, démarrage, **mode maintenance** (`main-maintenance` avec la précision de l'administrateur,
`main-maintenance-plain` sans précision, jeu non installé) ; `main-multi` : plusieurs jeux (exemple
`launcher.multi-jeux.example.json`).
* **Fenêtres** : paramètres du jeu et du launcher, notes de version agrandies, choix du dossier
d'installation, mise à jour du launcher (téléchargement, version vérifiée, erreur), boîtes de dialogue
d'installation, mise à jour du launcher (téléchargement, version vérifiée, version prioritaire
`launcher-update-priority`, erreur), boîtes de dialogue
(information, succès, question, avertissement, erreur, action irréversible avec option), menu de la zone
de notification.
* **Fenêtre « Aide »** (`feedback-*`, bouton de la barre de titre) : choix du type de demande, avis noté,
bug rempli avec une capture d'écran (formulaire qui défile : pages `.p2.png`…), champs obligatoires
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), et une
version prête à publier.
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`).
@@ -57,6 +94,15 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
(`%USERPROFILE%\.launcher-keys`) n'est lue.
* Projet séparé, jamais distribué aux joueurs : le launcher ne le référence pas.
## Tenir l'outil à jour
L'outil sert au débogage : **chaque nouvelle fenêtre ou nouveau menu doit y être ajouté** dans la même
modification. Une page du menu d'administration est capturée automatiquement, mais ses routes de l'API sont
à simuler dans `Fakes\FakeApi.cs` (sinon le rapport les signale) et ses états intéressants (fiche ouverte,
élément choisi…) à ajouter dans `Scenes\AdminScenes.cs`. Une fenêtre du joueur s'ajoute dans
`Scenes\PlayerScenes.cs`, ou dans un ensemble de scènes à part (`Scenes\FeedbackScenes.cs`) déclaré dans
`SnapshotRunner.cs`.
## Limites
* Les états de survol, d'appui et les menus ou listes déroulantes ouverts (fenêtres surgissantes)
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff. Show more